diff --git a/CHANGELOG.md b/CHANGELOG.md index b561cb9..6feaeb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented in this file. +## [0.0.30] - 2026-09-12 + +### Changed + +- Prohibited dedicated License headings and conventional standalone license-description paragraphs in Swift package READMEs while retaining root license files and standard license badges. + ## [0.0.29] - 2026-09-12 ### Added diff --git a/Guidelines/Packages.md b/Guidelines/Packages.md index c5a27d0..55b1b17 100644 --- a/Guidelines/Packages.md +++ b/Guidelines/Packages.md @@ -53,7 +53,8 @@ The common package baseline is Swift, Xcode, Platforms, License, and CI. Add opt - Use descriptive `alt` text. Preserve a repository's established Xcode badge convention when the label intentionally records the last verified Xcode version. - Prefer dynamic Updated and Revision badges backed by repository history or releases so maintainers do not edit dates and versions by hand. - Do not advertise a platform, integration, package manager, or agent that the repository does not support. -- Keep the repository's license in a root `LICENSE` file when reuse or redistribution is permitted. A README license heading is optional; the badge is a summary, not the license grant. +- Keep the repository's license in a root `LICENSE` file when reuse or redistribution is permitted. +- Retain the README license badge when the standard badge set applies, but do not add a dedicated License heading or license-description section to a package README. GitHub already presents the repository license beside the README, and the badge provides the summary without duplicating license prose. - Do not add a license to an existing repository without the owner's explicit choice of terms. ## Package boundaries diff --git a/README.md b/README.md index da03cc0..821a309 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ From the consumer repository root, install a tagged release: git subtree add \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.29 \ + 0.0.30 \ --squash ``` @@ -147,7 +147,7 @@ Review the target release's changelog, then pull it deliberately: git subtree pull \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.29 \ + 0.0.30 \ --squash ``` diff --git a/Scripts/validate_consumer_setup.swift b/Scripts/validate_consumer_setup.swift index cf6a9a5..43f41e3 100755 --- a/Scripts/validate_consumer_setup.swift +++ b/Scripts/validate_consumer_setup.swift @@ -21,6 +21,10 @@ let strictFormatCommandPattern = let mutatingFormatCommandPattern = #"(?m)^[ \t]*(?:-\s+)?(?:run:\s*)?(?:\./)?AgentGuidelines/Scripts/swift_format\.sh\s+format(?:-and-lint)?(?=\s|\\|$)"# let generatedAttributePattern = #"(?m)^\s*AgentGuidelines/\*\*\s+linguist-generated\s*$"# +let readmeLicenseHeadingPattern = #"(?im)^[ \t]{0,3}#{1,6}[ \t]+license[ \t]*#*[ \t]*$"# +let readmeSetextUnderlinePattern = #"^[ \t]{0,3}(?:=+|-+)[ \t]*$"# +let readmeLicenseParagraphPattern = + #"(?i)^.+?\s+is\s+available\s+under\s+the\s+.+?\s+license\.\s+See\s+\[LICENSE\]\((?:\./)?LICENSE\)\.$"# /// Parsed consumer-validation command-line values. struct Arguments { @@ -167,6 +171,75 @@ func files(in directory: URL, extensions: Set) -> [URL] { return values.filter { extensions.contains($0.pathExtension.lowercased()) }.sorted { $0.path < $1.path } } +/// Returns a Markdown fence marker and run length when a line can open or close a fenced code block. +func markdownFence(in line: String) -> (character: Character, length: Int, suffix: Substring)? { + let indentation = line.prefix(while: { $0 == " " }).count + let content = line.dropFirst(indentation) + guard indentation <= 3, let character = content.first, character == "`" || character == "~" + else { + return nil + } + let length = content.prefix(while: { $0 == character }).count + guard length >= 3 else { return nil } + return (character, length, content.dropFirst(length)) +} + +/// Returns whether a line is eligible to be the title of a Setext License heading. +func isSetextLicenseTitle(_ line: String) -> Bool { + let indentation = line.prefix(while: { $0 == " " }).count + guard indentation <= 3 else { return false } + return line.dropFirst(indentation).trimmingCharacters(in: .whitespaces) + .localizedCaseInsensitiveCompare("License") == .orderedSame +} + +/// Validates package README license content without inspecting vendored or non-package documentation. +func validatePackageReadme(consumerRoot: URL, errors: inout [String]) { + guard FileManager.default.fileExists(atPath: consumerRoot.appendingPathComponent("Package.swift").path) else { + return + } + let readmeURL = consumerRoot.appendingPathComponent("README.md") + guard FileManager.default.fileExists(atPath: readmeURL.path), + let contents = readText(readmeURL, errors: &errors, label: "consumer package README") + else { + return + } + var outsideFences: [String] = [] + var activeFence: (character: Character, length: Int)? + for line in contents.components(separatedBy: .newlines) { + if let currentFence = activeFence { + if let marker = markdownFence(in: line), marker.character == currentFence.character, + marker.length >= currentFence.length, + marker.suffix.trimmingCharacters(in: .whitespaces).isEmpty + { + activeFence = nil + } + outsideFences.append("") + } else if let marker = markdownFence(in: line), + marker.character == "~" || !marker.suffix.contains("`") + { + activeFence = (marker.character, marker.length) + outsideFences.append("") + } else { + outsideFences.append(line) + } + } + let governedContents = outsideFences.joined(separator: "\n") + let lines = governedContents.components(separatedBy: .newlines) + let hasSetextLicenseHeading = lines.indices.dropLast().contains { index in + isSetextLicenseTitle(lines[index]) && !matches(readmeSetextUnderlinePattern, in: lines[index + 1]).isEmpty + } + if !matches(readmeLicenseHeadingPattern, in: governedContents).isEmpty || hasSetextLicenseHeading { + errors.append("consumer package README: README.md must not contain a dedicated License heading") + } + let paragraphs = governedContents.components(separatedBy: "\n\n").map { + $0.split(whereSeparator: \.isNewline).joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + if paragraphs.contains(where: { !matches(readmeLicenseParagraphPattern, in: $0).isEmpty }) { + errors.append("consumer package README: README.md must not contain a standalone license-description paragraph") + } +} + /// Validates non-mutating Swift-format CI integration. func validateSwiftFormatCI(consumerRoot: URL, errors: inout [String]) { let workflowsRoot = consumerRoot.appendingPathComponent(".github/workflows") @@ -304,6 +377,7 @@ func validateConsumerSetup( if swiftFormatAdopted { validateSwiftFormatCI(consumerRoot: consumerRoot, errors: &errors) } + validatePackageReadme(consumerRoot: consumerRoot, errors: &errors) } /// Parses command-line arguments. diff --git a/Scripts/validate_guidelines.swift b/Scripts/validate_guidelines.swift index bbbea8d..3139e94 100755 --- a/Scripts/validate_guidelines.swift +++ b/Scripts/validate_guidelines.swift @@ -594,6 +594,10 @@ func validateExternalDependencyPolicy(_ errors: inout [String]) { "[external dependency policy](Development.md#external-dependencies)": "package dependency policy pointer", "must not introduce or conceal a third-party runtime dependency": "first-party package boundary", "guideline-mandated tooling dependency": "DocC tooling exception", + "root `LICENSE` file": "root license-file retention", + "Retain the README license badge": "README license-badge retention", + "do not add a dedicated License heading or license-description section": + "README license-section prohibition", ] for (value, description) in required where !contents.contains(value) { errors.append("Guidelines/Packages.md: missing \(description): '\(value)'") diff --git a/Tests/run_tests.swift b/Tests/run_tests.swift index 21d3eaa..516c7c8 100755 --- a/Tests/run_tests.swift +++ b/Tests/run_tests.swift @@ -74,6 +74,47 @@ func initializeRepository(at root: URL) throws { try git("config", "user.name", "Agent", in: root) } +func createConsumerFixture(at root: URL, isPackage: Bool, readme: String? = nil) throws { + try fileManager.createSymbolicLink( + at: root.appendingPathComponent("AgentGuidelines"), withDestinationURL: repositoryRoot) + try fileManager.copyItem( + at: repositoryRoot.appendingPathComponent("Templates/AGENTS.md"), + to: root.appendingPathComponent("AGENTS.md")) + try write("AgentGuidelines/** linguist-generated\n", to: root.appendingPathComponent(".gitattributes")) + let skillParent = root.appendingPathComponent(".agents/skills") + try fileManager.createDirectory(at: skillParent, withIntermediateDirectories: true) + try fileManager.createSymbolicLink( + at: skillParent.appendingPathComponent("agent-guidelines-audit"), + withDestinationURL: repositoryRoot.appendingPathComponent(".agents/skills/agent-guidelines-audit") + ) + try fileManager.createSymbolicLink( + at: root.appendingPathComponent(".swift-format"), + withDestinationURL: repositoryRoot.appendingPathComponent("Configurations/Swift/.swift-format")) + try fileManager.createSymbolicLink( + at: root.appendingPathComponent(".editorconfig"), + withDestinationURL: repositoryRoot.appendingPathComponent("Configurations/Swift/.editorconfig")) + try write( + """ + name: CI + on: + pull_request: + push: + branches: [main] + jobs: + swift-format: + steps: + - run: AgentGuidelines/Scripts/swift_format.sh lint-strict Package.swift Sources Tests + """ + "\n", + to: root.appendingPathComponent(".github/workflows/ci.yml") + ) + if isPackage { + try write("// swift-tools-version: 6.0\n", to: root.appendingPathComponent("Package.swift")) + } + if let readme { + try write(readme, to: root.appendingPathComponent("README.md")) + } +} + func localization(_ value: String, state: String = "translated") -> String { #"{"stringUnit":{"state":"\#(state)","value":"\#(value)"}}"# } @@ -469,6 +510,101 @@ let tests: [(String, () throws -> Void)] = [ } } ), + ( + "consumer validator rejects package README License headings", + { + for readme in [ + "# Package\n\n## License\n", "# Package\n\n#### lIcEnSe ####\n", + "# Package\n\nLicense\n-------\n", "# Package\n\nLICENSE\n=======\n", + ] { + try withTemporaryDirectory { root in + try createConsumerFixture(at: root, isPackage: true, readme: readme) + let result = try run([ + script("Scripts/validate_consumer_setup.swift"), "--consumer-root", root.path, + ]) + try require(!result.succeeded, "package License heading unexpectedly passed") + try require(result.output.contains("must not contain a dedicated License heading"), result.output) + } + } + } + ), + ( + "consumer validator rejects standalone package README license prose", + { + try withTemporaryDirectory { root in + try createConsumerFixture( + at: root, + isPackage: true, + readme: "# Example\n\nExample is available under the MIT license. See [LICENSE](LICENSE).\n" + ) + let result = try run([ + script("Scripts/validate_consumer_setup.swift"), "--consumer-root", root.path, + ]) + try require(!result.succeeded, "standalone package license prose unexpectedly passed") + try require( + result.output.contains("must not contain a standalone license-description paragraph"), + result.output + ) + } + } + ), + ( + "consumer validator accepts package license badge and absent README", + { + try withTemporaryDirectory { root in + try createConsumerFixture( + at: root, + isPackage: true, + readme: """ + # Example + + ![License](https://img.shields.io/badge/License-MIT-green.svg) + + See [LICENSE](LICENSE) when verifying redistribution terms. + + ````markdown + ```markdown + ## License section example + + Example is available under the MIT license. See [LICENSE](LICENSE). + ``` + ```` + + ~~~text + # License + ~~~ + """ + "\n" + ) + let result = try run([ + script("Scripts/validate_consumer_setup.swift"), "--consumer-root", root.path, + ]) + try require(result.succeeded, result.output) + } + try withTemporaryDirectory { root in + try createConsumerFixture(at: root, isPackage: true) + let result = try run([ + script("Scripts/validate_consumer_setup.swift"), "--consumer-root", root.path, + ]) + try require(result.succeeded, result.output) + } + } + ), + ( + "consumer validator limits README license policy to package roots", + { + try withTemporaryDirectory { root in + try createConsumerFixture(at: root, isPackage: false, readme: "# App\n\n## License\n") + try write( + "# License\n\nStandalone documentation may discuss licensing.\n", + to: root.appendingPathComponent("Documentation/README.md") + ) + let result = try run([ + script("Scripts/validate_consumer_setup.swift"), "--consumer-root", root.path, + ]) + try require(result.succeeded, result.output) + } + } + ), ( "consumer validator accepts synchronized integration", { diff --git a/VERSION b/VERSION index 369bd4c..f092e2b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.29 +0.0.30