diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 1140cd4..d738072 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -4,6 +4,10 @@ # Enforces github-discipline.md Rule 5.1 (no AI attribution) and # Rule 5.2 (no em dashes U+2014) at the commit boundary. # +# Tool-agnostic: synced from the canonical +# mihaela-agents/templates/git-hooks/commit-msg-style-check, plus an author / +# committer identity guard for AI-agent commit identities. +# # Install (per repo): git config core.hooksPath .githooks # # Portable: uses only BSD-compatible grep (no -P, no \x escapes in patterns). @@ -35,16 +39,22 @@ if LC_ALL=C printf '%s' "$CONTENT" | grep -qF -- "$EMDASH"; then FAIL=1 fi -# Rule 5.1: literal AI-attribution phrases. -LITERAL_PATTERNS=( - 'Co-Authored-By: Claude' - 'Co-authored-by: Claude' - 'Generated with Claude Code' - 'Generated with Claude' - 'Generated by Claude' - 'with help from Claude' - 'Powered by Claude' - 'powered by Claude' +# Rule 5.1: AI-attribution phrases. Tool-agnostic: any assistant or its vendor, +# not just the one in use. Bare tool names are NOT flagged (that would +# false-positive on legitimate text like "fix cursor blink"); only +# attribution-context mentions are. Case-insensitive, BSD-grep -E compatible. +AI_TOOLS='Claude|Anthropic|Codex|OpenAI|ChatGPT|GPT-[0-9]|Cursor|Copilot|Gemini|Google AI' +ATTRIB_REGEX="(Co-Authored-By|Co-authored-with|Generated (with|by)|Created (with|by)|Powered by|with help from|written by|authored by)[: ].*(${AI_TOOLS})" +if printf '%s' "$CONTENT" | grep -qiE -- "$ATTRIB_REGEX"; then + printf 'commit-msg: forbidden AI-attribution phrase (names an AI tool/vendor).\n' >&2 + printf ' Rule: github-discipline.md Rule 5.1 (tool-agnostic)\n' >&2 + printf ' Matched lines:\n' >&2 + printf '%s\n' "$CONTENT" | grep -niE -- "$ATTRIB_REGEX" | sed 's/^/ /' >&2 + FAIL=1 +fi + +# Generic self-reference tells, independent of any vendor name. +GENERIC_PATTERNS=( 'as an AI' 'As an AI' 'this commit was generated' @@ -52,7 +62,7 @@ LITERAL_PATTERNS=( 'this change was generated' 'This change was generated' ) -for p in "${LITERAL_PATTERNS[@]}"; do +for p in "${GENERIC_PATTERNS[@]}"; do if printf '%s' "$CONTENT" | grep -qF -- "$p"; then printf 'commit-msg: forbidden AI-attribution phrase: %s\n' "$p" >&2 printf ' Rule: github-discipline.md Rule 5.1\n' >&2 @@ -88,48 +98,15 @@ for p in "${PREAMBLE_PATTERNS[@]}"; do fi done -# Rule 5.1: Cursor attribution. Reject Cursor co-author trailers, the agent -# email, any @cursor.com address, and "generated with Cursor" style tells. -# Case-insensitive on purpose: cursoragent@cursor.com, Co-authored-by: Cursor. -CURSOR_LITERALS=( - 'Co-Authored-By: Cursor' - 'Co-authored-by: Cursor' - 'Generated with Cursor' - 'Generated by Cursor' - 'with help from Cursor' - 'Powered by Cursor' -) -for p in "${CURSOR_LITERALS[@]}"; do - if printf '%s' "$CONTENT" | grep -qiF -- "$p"; then - printf 'commit-msg: forbidden Cursor attribution: %s\n' "$p" >&2 - printf ' Rule: github-discipline.md Rule 5.1\n' >&2 - FAIL=1 - fi -done -if printf '%s' "$CONTENT" | grep -qiE -- '@cursor\.com'; then - printf 'commit-msg: forbidden Cursor email (@cursor.com) in commit message.\n' >&2 - printf ' Rule: github-discipline.md Rule 5.1\n' >&2 - FAIL=1 -fi - -# Rule 5.1 is vendor-neutral: reject attribution for every AI assistant, not just -# Claude/Cursor. Vendor names live in a list so the hook stays tool-agnostic. -AI_VENDORS="Codex Copilot Gemini ChatGPT OpenAI Anthropic" -AI_PREFIXES='Co-Authored-By:|Co-authored-by:|Generated with|Generated by|with help from|Powered by' -for vendor in $AI_VENDORS; do - if printf '%s' "$CONTENT" | grep -qiE -- "(${AI_PREFIXES}) ${vendor}"; then - printf 'commit-msg: forbidden AI attribution for %s.\n' "$vendor" >&2 - printf ' Rule: github-discipline.md Rule 5.1 (tool-agnostic)\n' >&2 - FAIL=1 - fi -done - -# Author / committer identity must not be Cursor. git var resolves the exact +# Author / committer identity must not be an AI agent. git var resolves the exact # identity (including --author / GIT_*_IDENT overrides) this commit will record. +# Conservative on names (a human may be named Claude or Gemini); matches agent +# email domains and the Cursor agent identity, which injects itself in practice. +IDENT_REGEX='@cursor\.com|cursoragent|(^|[^[:alpha:]])[Cc]ursor [Aa]gent([^[:alpha:]]|$)' for ident in "$(git var GIT_AUTHOR_IDENT 2>/dev/null)" "$(git var GIT_COMMITTER_IDENT 2>/dev/null)"; do [ -z "$ident" ] && continue - if printf '%s' "$ident" | grep -qiE -- '@cursor\.com|(^|[^[:alpha:]])cursor(agent)?([^[:alpha:]]|$)'; then - printf 'commit-msg: forbidden Cursor identity as author/committer: %s\n' "$ident" >&2 + if printf '%s' "$ident" | grep -qiE -- "$IDENT_REGEX"; then + printf 'commit-msg: forbidden AI-agent identity as author/committer: %s\n' "$ident" >&2 printf ' Fix: set git config user.name / user.email to your own identity.\n' >&2 FAIL=1 fi diff --git a/.githooks/pre-commit b/.githooks/pre-commit index b4e9b63..f4fc6e9 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -75,10 +75,8 @@ SKIP_PATTERNS=( '/pre-commit$' '/pre-push$' '/check-style\.sh$' + '/check-commit-attribution\.sh$' '/check-staged-style\.sh$' - # The CI workflow that enforces the same Cursor prohibition server-side; it - # must quote the forbidden tokens to scan for them. - '^\.github/workflows/no-cursor-attribution\.yml$' ) # Filter STAGED_FILES through the skip list. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ef36ff9..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: CI - -# PureXML is a root Swift package. CI must prove macOS, Linux, Windows, and WASM. - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - schedule: - - cron: "0 6 * * 1" - -permissions: - contents: read - issues: read - -jobs: - macos: - name: macOS - runs-on: macos-latest - steps: - - uses: actions/checkout@v6 - - name: Install format and lint tools - run: brew install swiftformat swiftlint - - name: Repository gates - env: - GH_TOKEN: ${{ github.token }} - run: | - bash scripts/check-style.sh - bash scripts/check-namespacing.sh - bash scripts/check-forbidden-patterns.sh - - name: Format and lint - run: | - swiftformat . --config .swiftformat --lint - swiftlint --config .swiftlint.yml --strict - - name: Build and test - run: | - swift build - swift test - swift build -c release - swift test -c release - - linux: - name: Linux - runs-on: ubuntu-latest - container: swift:6.2 - steps: - - uses: actions/checkout@v6 - - name: Build and test - run: bash scripts/check-linux.sh - - windows: - name: Windows - runs-on: windows-2022 - timeout-minutes: 45 - steps: - - uses: actions/checkout@v6 - - name: Install Swift - uses: compnerd/gha-setup-swift@main - with: - swift-version: swift-6.3.2-release - swift-build: 6.3.2-RELEASE - - name: Build and test - shell: pwsh - run: ./scripts/check-windows.ps1 - - wasm: - name: WASM - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install Swift toolchain dependencies - run: | - sudo apt-get update - sudo apt-get install -y libcurl4-openssl-dev - - name: Install swiftly - run: | - curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz - tar zxf swiftly.tar.gz - ./swiftly init --quiet-shell-followup --assume-yes --skip-install - - name: Install Swift 6.3.2 and Wasm SDK - run: | - . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" - swiftly install --use 6.3.2 - swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c - - name: Build for WASM - run: | - . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" - bash scripts/check-wasm.sh diff --git a/.github/workflows/no-cursor-attribution.yml b/.github/workflows/no-cursor-attribution.yml deleted file mode 100644 index 23cf811..0000000 --- a/.github/workflows/no-cursor-attribution.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: No Cursor attribution - -# Server-side companion to the local .githooks/commit-msg and pre-commit guards. -# Fails the check if any commit pushed (or in a PR) carries Cursor attribution: -# a Cursor co-author trailer, an @cursor.com email, or a "generated with Cursor" -# tell, in the commit message or in the author/committer identity. - -on: - push: - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - scan: - name: Scan commits - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Reject Cursor attribution - env: - EVENT: ${{ github.event_name }} - PR_BASE: ${{ github.event.pull_request.base.sha }} - PR_HEAD: ${{ github.event.pull_request.head.sha }} - PUSH_BEFORE: ${{ github.event.before }} - SHA: ${{ github.sha }} - run: | - set -euo pipefail - - # Resolve the set of commits to scan. - if [ "$EVENT" = "pull_request" ]; then - range="${PR_BASE}..${PR_HEAD}" - elif [ -n "${PUSH_BEFORE:-}" ] && [ "$PUSH_BEFORE" != "0000000000000000000000000000000000000000" ]; then - range="${PUSH_BEFORE}..${SHA}" - else - # New branch / tag push or manual run: scan just the tip commit. - range="${SHA}~1..${SHA}" - git rev-parse "${SHA}~1" >/dev/null 2>&1 || range="$SHA" - fi - echo "Scanning commit range: $range" - - # Emit message + author + committer for each commit, then grep. - fmt='commit %H%nauthor: %an <%ae>%ncommitter: %cn <%ce>%n%B' - if printf '%s' "$range" | grep -q '\.\.'; then - log=$(git log --format="$fmt" "$range") - else - log=$(git log -1 --format="$fmt" "$range") - fi - - pattern='cursoragent@cursor\.com|@cursor\.com|Co-authored-by:[[:space:]]*Cursor|Generated (with|by) Cursor|with help from Cursor|Powered by Cursor' - if printf '%s\n' "$log" | grep -niE -- "$pattern"; then - echo "::error::Cursor attribution found in the commits above. Strip it (rewrite the commits) before pushing." - exit 1 - fi - echo "No Cursor attribution found." diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml new file mode 100644 index 0000000..f59d4ed --- /dev/null +++ b/.github/workflows/style.yml @@ -0,0 +1,34 @@ +name: Style and namespacing + +# One workflow per concern so each README badge tracks exactly one job and is +# truthful. This job runs the repository gates (style, commit-message +# attribution, namespacing, forbidden patterns). + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + issues: read + +jobs: + style: + name: Style and namespacing + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Repository gates + env: + GH_TOKEN: ${{ github.token }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + bash scripts/check-style.sh + bash scripts/check-commit-attribution.sh + bash scripts/check-namespacing.sh + bash scripts/check-forbidden-patterns.sh diff --git a/.github/workflows/swift-linux.yml b/.github/workflows/swift-linux.yml new file mode 100644 index 0000000..6dd549e --- /dev/null +++ b/.github/workflows/swift-linux.yml @@ -0,0 +1,20 @@ +name: Swift (Linux) + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + swift-linux: + name: Swift (Linux) + runs-on: ubuntu-latest + container: swift:6.2 + steps: + - uses: actions/checkout@v6 + - name: Build and test + run: bash scripts/check-linux.sh diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml new file mode 100644 index 0000000..7732d7e --- /dev/null +++ b/.github/workflows/swift-macos.yml @@ -0,0 +1,29 @@ +name: Swift (macOS) + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + swift-macos: + name: Swift (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - name: Install format and lint tools + run: brew install swiftformat swiftlint + - name: Format and lint + run: | + swiftformat . --config .swiftformat --lint + swiftlint --config .swiftlint.yml --strict + - name: Build and test + run: | + swift build + swift test + swift build -c release + swift test -c release diff --git a/.github/workflows/swift-wasm.yml b/.github/workflows/swift-wasm.yml new file mode 100644 index 0000000..0b21003 --- /dev/null +++ b/.github/workflows/swift-wasm.yml @@ -0,0 +1,35 @@ +name: Swift (WASM) + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + swift-wasm: + name: Swift (WASM) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install Swift toolchain dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcurl4-openssl-dev + - name: Install swiftly + run: | + curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz + tar zxf swiftly.tar.gz + ./swiftly init --quiet-shell-followup --assume-yes --skip-install + - name: Install Swift 6.3.2 and Wasm SDK + run: | + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + swiftly install --use 6.3.2 + swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c + - name: Build for WASM + run: | + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" + bash scripts/check-wasm.sh diff --git a/.github/workflows/swift-windows.yml b/.github/workflows/swift-windows.yml new file mode 100644 index 0000000..7589c29 --- /dev/null +++ b/.github/workflows/swift-windows.yml @@ -0,0 +1,26 @@ +name: Swift (Windows) + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + swift-windows: + name: Swift (Windows) + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + - name: Install Swift + uses: compnerd/gha-setup-swift@main + with: + swift-version: swift-6.3.2-release + swift-build: 6.3.2-RELEASE + - name: Build and test + shell: pwsh + run: ./scripts/check-windows.ps1 diff --git a/README.md b/README.md index f41e1f2..5bc4ce5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # PureXML -[![macOS CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=macOS)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) -[![Linux CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=Linux)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) -[![WASI CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=WASI)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) +[![Style and namespacing](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml) +[![Swift macOS](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml) +[![Swift Linux](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml) +[![Swift Windows](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml) +[![Swift WASM](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) PureXML is a dependency-free XML package written entirely in Swift. @@ -126,27 +129,66 @@ flowchart TB ## Status -The node model, the emitter, and a streaming parser are implemented and usable -today. +PureXML is a working, dependency-free XML library today: parse, emit, validate, +query, and stream documents on macOS, Linux, Windows, and WASM. The test suite +currently runs **135 tests in 18 suites** (`swift test`). -- **Model** (`PureXML.Model`): `Node`, `Element`, `Attribute`, `QualifiedName`. - Preserves document order and the distinction between text, CDATA, comments, - and processing instructions. -- **Emitting** (`PureXML.Emitting`): `Serializer` turns a node tree into - well-formed XML, with pretty-printed and compact options and correct text and - attribute escaping. -- **Validation** (`PureXML.Validation`): structural checks such as duplicate - attribute names. Schema validation (DTD/XSD/RELAX NG) is out of scope for the - library target. -- **Parsing** (`PureXML.Parsing`): a streaming, iterative parser. `EventReader` - is a pull-based event core that consumes input through a character-source - closure and emits one `Event` at a time, never holding the whole document in - memory (it drives chunked input). `Parser` builds a `Model.Node` tree - iteratively over the event core. Handles elements, attributes, text, the five - predefined entities and numeric character references, comments, CDATA, and - processing instructions. Safe by default: `` is rejected, which - removes the DTD-based threat classes (XXE, entity-expansion DoS). DTD support, - namespaces-as-resolved-URIs, and validation are deliberate future layers. +### Shipped (libxml2-aligned surface) + +- **Model** (`PureXML.Model`): `Node`, `Element`, `Attribute`, `QualifiedName`, + and a mutable `TreeNode` for in-place editing (insert, remove, replace, copy, + parent navigation). +- **Parsing** (`PureXML.Parsing`): iterative streaming parser over strings, + bytes, or incremental character/byte sources. Pull events via `EventReader`, + SAX2-style callbacks via `SAXHandler`, and a pull cursor via `TextReader` + (libxml2 `xmlTextReader`). Handles elements, attributes, text, predefined and + numeric entities, comments, CDATA, and processing instructions. Namespace + bindings (`xmlns`) are resolved to URIs on parse. +- **Encoding**: UTF-8/16/32 (with BOM sniffing), ISO-8859-1, and Windows-1252 + from raw bytes or streaming byte input. +- **DTD (opt-in)**: `` is **off by default** (`Limits.allowDoctype: + false`) to keep XXE and entity-expansion closed. When enabled, the internal + subset is parsed; internal general entities expand with a bounded cap; external + entities and external subsets are refused unless an injected + `EntityResolver` supplies replacement text. +- **Validation** (`PureXML.Validation`): structural checks (duplicate attributes, + etc.) plus **DTD validation** against declared content models, attribute lists, + and ID/IDREF rules (`DTDSchema`, `validateAgainstInternalDTD`). XSD, RELAX NG, + and Schematron remain open epics (#2, #25). +- **Emitting** (`PureXML.Emitting`): `Serializer` (save-option parity) and an + incremental `Writer` (libxml2 `xmlTextWriter`) with compact and pretty modes. +- **XPath** (`PureXML.XPath`): a **subset** of XPath 1.0 (location paths, common + axes, node tests, basic predicates). Full XPath 1.0 is epic #21. + +### Safe defaults + +By default the parser rejects `` and refuses every external reference +(`EntityResolver.refusing`). Enable DTD processing only when you need internal +subset validation or internal entities, and wire a resolver only for trusted, +in-memory replacements. Network fetching (`nanohttp`/`nanoftp`) and libxml2's +threading/memory infrastructure are deliberate non-goals. + +### Remaining toward full libxml2 feature parity + +**12 of 20 epics are still open**; each now has filed child issues (#4–#60). The +shipped eight cover the core I/O stack; most remaining work is whole standards: + +| Open epic | Child issues | What it adds | +|---|---|---| +| #1 Push / feed API | #4–#7 | Resumable scanner + push parser | +| #20 HTML parser | #36–#39 | Tag-soup HTML parse and serialize | +| #21 Full XPath 1.0 | #40–#43 | Unlocks #22, #23, #25, #3 | +| #22 xmlPattern | #44–#45 | Streaming pattern match (needs #21) | +| #23 XPointer | #46–#47 | element() / xpointer() (needs #21) | +| #24 XInclude | #48–#51 | URI, xml:base, xi:include | +| #25 Schematron | #52–#53 | Rule validation (needs #21) | +| #26 C14N | #54–#56 | Canonical XML variants | +| #27 XML Catalog | #57–#58 | OASIS catalog resolver | +| #30 → #2 | #59–#60, #8–#13 | Regex engine, then XSD / RELAX NG | +| #3 XSLT 1.0 | #14–#18 | Stylesheet transform (needs #21) | + +**40 open enhancement issues** under those epics (plus the 12 epic parents). Critical +path: **#30 → #2** (schema) and **#21 → #22/#23/#25/#3** (query/transform). ## Usage @@ -171,6 +213,16 @@ var reader = PureXML.events(xml) while let event = try reader.next() { // handle .startElement / .characters / .endElement / ... } + +// DTD validation (opt-in: allowDoctype + optional resolver for trusted externals). +let issues = try PureXML.validateAgainstInternalDTD( + xmlWithDoctype, + limits: .init(allowDoctype: true), +) + +// XPath subset over a parsed tree. +let titles = try PureXML.xpath("//title", over: node) + .compactMap(\.element) ``` ## Attribution diff --git a/Sources/API.swift b/Sources/API.swift index 35d08d5..b2460a8 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -1,9 +1,5 @@ public extension PureXML { /// Parses an XML document into a ``Model/Node`` tree. - /// - /// The parser is still being built; this entry point currently raises - /// ``Parsing/ParseError/notImplemented(_:)``. The serializer and model are - /// usable today for building and emitting trees. static func parse( _ xml: String, limits: Parsing.Limits = .default, diff --git a/docs/rules/git-discipline.md b/docs/rules/git-discipline.md index e38a300..db4e1ad 100644 --- a/docs/rules/git-discipline.md +++ b/docs/rules/git-discipline.md @@ -238,6 +238,54 @@ A repo that adopts these rules should ship a mechanical backstop: **Why mechanical enforcement matters.** A disciplined author following Rule 5.2 can still push a commit whose message contains an em dash, because nothing at the commit boundary stops it. A 3-line `commit-msg` hook prevents this entire failure class at write time, after which `git push` cannot carry a violation regardless of who or what wrote the message. Discipline scales with attention; hooks do not. +## 8. CI workflows and truthful badges + +### Rule 8.1: One workflow file per concern, one badge per workflow + +Each CI concern (style/gates, and each build-or-test platform) is its own workflow file with a unique `name:`. A README badge points at that one workflow's status endpoint, so the badge tracks exactly one job and is truthful. + +Do NOT use a single multi-job workflow (`ci.yml`) with several shields.io `?label=...` badges that all point at the same workflow. The `actions/workflow/status` endpoint reports the whole-workflow status; the `label=` is cosmetic, so every such badge renders the same result. A failure in any one job turns all of them red, and none of them tracks its named platform. That is a lying badge. + +Canonical layout (one file each, names are the badge labels): + +``` +.github/workflows/style.yml # name: Style and namespacing +.github/workflows/swift-macos.yml # name: Swift (macOS) +.github/workflows/swift-linux.yml # name: Swift (Linux) +.github/workflows/swift-windows.yml # name: Swift (Windows) +.github/workflows/swift-wasm.yml # name: Swift (WASM) +``` + +### Rule 8.2: Native per-workflow badge markdown, plus a license badge + +Use GitHub's native per-workflow badge (it reflects the real status of that one workflow), not a shields.io workflow-status badge with a cosmetic label: + +```markdown +[![Style and namespacing](https://github.com///actions/workflows/style.yml/badge.svg)](https://github.com///actions/workflows/style.yml) +[![Swift macOS](https://github.com///actions/workflows/swift-macos.yml/badge.svg)](https://github.com///actions/workflows/swift-macos.yml) +[![Swift Linux](https://github.com///actions/workflows/swift-linux.yml/badge.svg)](https://github.com///actions/workflows/swift-linux.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +``` + +The license badge is static and links to the repo's `LICENSE`; match the label to the actual license (`MIT`, `AGPL--3.0`, etc.). + +### Rule 8.3: Add a platform job and badge ONLY when that platform is genuinely possible + +A badge is a claim. Only claim a platform the repo can actually build or test on. Never ship a workflow or badge for a platform the package does not support; an absent badge is honest, a red-or-fictional badge is not. + +Decide possibility from the repo's topology (see `cross-platform.md`): + +- **Apple platforms (iOS, visionOS, tvOS, watchOS) and any UI build or test run on macOS runners only.** There is no "iOS CI on Linux" and no Windows/Linux UI. An iOS/visionOS app or a SwiftUI/UIKit/AppKit target is exercised by a macOS-hosted job (simulator or `xcodebuild -destination`), reported under the macOS badge or a dedicated `swift-ios.yml` that still `runs-on: macos`. Do not add a Linux or Windows badge for UI code. +- **Linux** badge only when the product builds on a Linux toolchain (libraries, servers, CLIs). A pure Apple-UI repo has no Linux badge. +- **Windows** badge only when the targets compile on Windows. If the test tooling is POSIX-only, Windows is a build-only gate: build the portable library targets (debug and release), skip the suite, and say so in a header comment. The badge then truthfully means "compiles on Windows." +- **WASM/WASI** badge only when the package builds for `wasm32-unknown-wasip1`. Code that needs Foundation networking, threads, or other WASI-absent surface does not get a WASM badge. WASM is typically a build gate; running wasm tests needs a separate runtime and is usually out of scope. + +When a platform can build but not run the full suite, the workflow is a build gate, not a test gate. The header comment states the scope so the green badge is not read as "tests pass" when it means "compiles." + +### Rule 8.4: Triggers and permissions are uniform across the split + +Every workflow uses the same trigger surface so the badges refresh together: `on: push: branches: [main]` plus `pull_request:` plus `workflow_dispatch:`. Keep `permissions:` least-privilege (`contents: read`, plus `issues: read` only where a gate reads issues, e.g. a roadmap check). The commit-message attribution gate (Rule 5.1) lives in the style workflow and needs `fetch-depth: 0` on checkout plus `BASE_SHA` / `HEAD_SHA` from the PR event to scan the commit range. + ## Triggers (when to load this rule) Load this rule when: @@ -251,5 +299,6 @@ Load this rule when: - Running `git commit --amend` (Rule 5.3) - Running `git push` (Rule 6.1, Rule 6.2) - Designing a tracker-discipline mechanism for a new repo (Rule 7) +- Setting up CI workflows or README status badges (Rule 8.1, Rule 8.2, Rule 8.3, Rule 8.4) Load on demand per the trigger; do not auto-load. diff --git a/scripts/check-commit-attribution.sh b/scripts/check-commit-attribution.sh new file mode 100755 index 0000000..205b531 --- /dev/null +++ b/scripts/check-commit-attribution.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Scan commit MESSAGES (and author/committer identity) in a range for +# AI-attribution tells. Tool-agnostic. +# +# Complements scripts/check-style.sh, which scans tracked FILE CONTENT via +# `git ls-files` and therefore never sees commit messages. An attribution +# trailer (e.g. "Co-authored-by: Cursor") lives in the message, not a file, so +# without this scan a poisoned trailer reaches the remote with no CI backstop. +# +# This is the merge-time backstop for github-discipline.md Rule 5.1; the local +# .githooks/commit-msg hook is the write-time backstop. Some assistants re-add a +# trailer on every commit/amend even when told not to, so neither the per-tool +# setting nor discipline alone is enough. +# +# Usage: +# scripts/check-commit-attribution.sh [] +# Range resolution (first that applies): +# 1) explicit "" argument (e.g. origin/main..HEAD) +# 2) BASE_SHA and HEAD_SHA environment variables -> BASE_SHA..HEAD_SHA +# 3) fallback: just HEAD (single commit) +# +# Portable: BSD-compatible grep, bash 3.2 (macOS) and bash 4+ (CI). + +set -u + +RANGE="${1:-}" +if [ -z "$RANGE" ]; then + if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then + RANGE="${BASE_SHA}..${HEAD_SHA}" + else + RANGE="HEAD~0..HEAD" + fi +fi + +# Resolve the commit list. If the range is unusable (shallow clone, unknown +# base), fall back to HEAD so we always scan at least the tip. +COMMITS=$(git rev-list "$RANGE" 2>/dev/null || true) +[ -z "$COMMITS" ] && COMMITS=$(git rev-parse HEAD) + +# Tool-agnostic AI-attribution detection (attribution-context only, so prose +# that merely contains a tool word is not a false positive). Mirrors +# .githooks/commit-msg and scripts/check-style.sh. +AI_TOOLS='Claude|Anthropic|Codex|OpenAI|ChatGPT|GPT-[0-9]|Cursor|Copilot|Gemini|Google AI' +ATTRIB_REGEX="^(Co-Authored-By|Co-authored-with|Generated (with|by)|Created (with|by)|Powered by|with help from|written by|authored by)[: ].*(${AI_TOOLS})" + +GENERIC_PATTERNS=( + 'as an AI' + 'this commit was generated' + 'this change was generated' +) + +# AI-tell signature emojis, kept as bytes so this file carries no glyphs. +EMOJI_TELLS=$(printf '\xf0\x9f\xa4\x96\n\xe2\x9c\xa8\n\xf0\x9f\xaa\x84\n\xf0\x9f\xa7\xa0\n\xf0\x9f\xa6\xbe\n\xf0\x9f\xa4\x9d') +EMDASH=$(printf '\xe2\x80\x94') + +# AI-agent author/committer identity. Conservative on names (a human may be +# named Claude or Gemini); matches agent email domains and the Cursor agent +# identity, which injects itself in practice. +IDENT_REGEX='@cursor\.com|cursoragent|(^|[^[:alpha:]])[Cc]ursor [Aa]gent([^[:alpha:]]|$)' + +FAIL=0 +for sha in $COMMITS; do + MSG=$(git log -1 --format='%B' "$sha") + IDENT=$(git log -1 --format='%an <%ae>%n%cn <%ce>' "$sha") + short=$(git rev-parse --short "$sha") + + # Attribution trailer/phrase, anchored to line start so a body sentence that + # describes the trailer (documentation) is not flagged, only a real trailer. + if printf '%s\n' "$MSG" | grep -qiE -- "$ATTRIB_REGEX"; then + printf 'commit-attribution: %s names an AI tool/vendor in attribution context.\n' "$short" >&2 + printf '%s\n' "$MSG" | grep -niE -- "$ATTRIB_REGEX" | sed 's/^/ /' >&2 + FAIL=1 + fi + + for p in "${GENERIC_PATTERNS[@]}"; do + if printf '%s' "$MSG" | grep -qiF -- "$p"; then + printf 'commit-attribution: %s contains forbidden phrase: %s\n' "$short" "$p" >&2 + FAIL=1 + fi + done + + if LC_ALL=C printf '%s' "$MSG" | grep -qF -- "$EMDASH"; then + printf 'commit-attribution: %s contains an em dash (U+2014).\n' "$short" >&2 + FAIL=1 + fi + + while IFS= read -r emoji; do + [ -z "$emoji" ] && continue + if LC_ALL=C printf '%s' "$MSG" | grep -qF -- "$emoji"; then + printf 'commit-attribution: %s contains an AI-signature emoji.\n' "$short" >&2 + FAIL=1 + break + fi + done <<<"$EMOJI_TELLS" + + if printf '%s' "$IDENT" | grep -qiE -- "$IDENT_REGEX"; then + printf 'commit-attribution: %s has an AI-agent author/committer identity:\n' "$short" >&2 + printf '%s\n' "$IDENT" | grep -niE -- "$IDENT_REGEX" | sed 's/^/ /' >&2 + FAIL=1 + fi +done + +if [ "$FAIL" -ne 0 ]; then + printf 'commit-attribution: gate failed. Rule: github-discipline.md Rule 5.1 / 5.2 (tool-agnostic).\n' >&2 + printf ' Rebuild the offending commit with a clean message (git commit-tree bypasses\n' >&2 + printf ' any assistant trailer injection), then re-push.\n' >&2 +fi +exit "$FAIL" diff --git a/scripts/check-style.sh b/scripts/check-style.sh index 2553486..4cc0bbb 100755 --- a/scripts/check-style.sh +++ b/scripts/check-style.sh @@ -16,8 +16,7 @@ EMDASH=$(printf '\xe2\x80\x94') # detect them, so exclude them from the phrase scan. is_enforcement_file() { case "$1" in - .githooks/*|scripts/check-style.sh) return 0 ;; - .github/workflows/no-cursor-attribution.yml) return 0 ;; + .githooks/*|scripts/check-style.sh|scripts/check-commit-attribution.sh) return 0 ;; *) return 1 ;; esac }