diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5991bf5..351f2a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,11 @@ on: branches: [main, dev, qa] pull_request: +# Rapid dev pushes supersede their own in-progress runs instead of stacking. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + # WHY THE SIBLING CHECKOUTS — this is NOT a monorepo. # # The committed Cargo.toml resolves libfreemkv from a git TAG (it is off @@ -18,8 +23,9 @@ on: # across the two repos could not be verified until release.sh cut the tags. # Green here meant "compiles against last month's library". # -# The fix mirrors the developer's local layout. Every repo is checked out into -# a subdirectory of $GITHUB_WORKSPACE, reproducing +# The fix mirrors the developer's local layout via the shared composite +# freemkv/.github//actions/checkout-siblings: every sibling repo is checked out +# into a subdirectory of $GITHUB_WORKSPACE, reproducing # ~/Developer/freemkv/{freemkv-keysources,libfreemkv,freemkv-unlock}, and a # generated .cargo/config.toml redirects the git-tag patch to those paths. # Config-level [patch.crates-io] wins over the manifest one, which is the same @@ -47,7 +53,7 @@ jobs: outputs: v: ${{ steps.msrv.outputs.v }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 - id: msrv shell: bash run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT" @@ -61,7 +67,7 @@ jobs: changelog: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 - name: Require a CHANGELOG entry for the crate version on main shell: bash run: | @@ -82,28 +88,14 @@ jobs: needs: resolve-msrv runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: freemkv-unlock - - name: Resolve siblings from their dev tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources # fmt runs on the MSRV pin; clippy runs on stable so new-lint debt is a # blocking gate the pinned toolchain would hide. Stable is installed first # for clippy; the MSRV pin goes second so it stays fmt's default toolchain. @@ -127,28 +119,14 @@ jobs: needs: resolve-msrv runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: freemkv-unlock - - name: Resolve siblings from their dev tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} @@ -165,28 +143,14 @@ jobs: deny: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: freemkv-unlock - - name: Resolve siblings from their dev tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources - uses: dtolnay/rust-toolchain@stable - uses: EmbarkStudios/cargo-deny-action@v2 with: @@ -213,28 +177,14 @@ jobs: if: github.ref_name == 'qa' || github.ref_name == 'main' runs-on: macos-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: freemkv-unlock - - name: Resolve siblings from their dev tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} @@ -254,28 +204,14 @@ jobs: needs: resolve-msrv runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: freemkv-unlock - - name: Resolve siblings from their dev tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} @@ -310,28 +246,14 @@ jobs: if: github.ref_name == 'qa' || github.ref_name == 'main' runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}" - path: freemkv-unlock - - name: Resolve siblings from their dev tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} diff --git a/.github/workflows/comment-guard.yml b/.github/workflows/comment-guard.yml index 0f8c2c9..f962514 100644 --- a/.github/workflows/comment-guard.yml +++ b/.github/workflows/comment-guard.yml @@ -1,17 +1,12 @@ name: comment-guard -# Caps explanatory prose by COMMENT INTENT (not visibility): -# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text) -# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded) -# // / /* */ asides — 3 lines (single-blank-separated runs count as one) -# Overflow belongs in the module //! or docs/.md with a one-line // pointer. - +# Thin caller → the canonical reusable guard in freemkv/.github (one definition +# for every repo). Fork PRs still run it; same-repo pushes aren't double-run. on: [push, pull_request] - +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: comment-guard: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - name: Run comment-guard - run: python3 ci/comment-guard.py + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork + uses: freemkv/.github/.github/workflows/guard-comment.yml@v1 diff --git a/.github/workflows/identity-guard.yml b/.github/workflows/identity-guard.yml index 9edaa3f..fb2925a 100644 --- a/.github/workflows/identity-guard.yml +++ b/.github/workflows/identity-guard.yml @@ -1,41 +1,14 @@ name: identity-guard -# Prevention: no commit may carry an unrelated-business or non-canonical author/ -# committer email. dev@example.com (a dead corp address) once slipped onto the -# release cascade + a batch of local commits; this fails the build if it recurs. + +# Thin caller → the canonical reusable guard in freemkv/.github. on: push: branches: [dev, qa, main] pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: identity: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - name: Reject banned author/committer emails - env: - BEFORE: ${{ github.event.before }} - AFTER: ${{ github.sha }} - EVENT: ${{ github.event_name }} - PR_BASE: ${{ github.event.pull_request.base.sha }} - PR_HEAD: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - # Unrelated business domains + a personal address normalized to the - # GitHub noreply. Real external contributors are unaffected. - BANNED='@example-a\.invalid|@example-b\.invalid|owner@example\.invalid' - if [ "$EVENT" = "pull_request" ]; then - range="$PR_BASE..$PR_HEAD" - elif [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then - range="$BEFORE..$AFTER" - else - range="$AFTER~1..$AFTER" - fi - bad=$(git log --format='%an <%ae>%n%cn <%ce>' "$range" 2>/dev/null | sort -u | grep -iE "$BANNED" || true) - if [ -n "$bad" ]; then - echo "::error::commit(s) carry a banned author/committer identity:"; echo "$bad" | sed 's/^/ /' - echo "::error::Commit as your GitHub noreply (1085847+MattJackson@users.noreply.github.com). Fix: git config user.email ; git commit --amend --reset-author." - exit 1 - fi - echo "identity-guard: clean" + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork + uses: freemkv/.github/.github/workflows/guard-identity.yml@v1 diff --git a/.github/workflows/leak-guard.yml b/.github/workflows/leak-guard.yml index 08fc8ae..c47f157 100644 --- a/.github/workflows/leak-guard.yml +++ b/.github/workflows/leak-guard.yml @@ -1,35 +1,11 @@ name: leak-guard -# Self-contained public-repo leak gate. Public CI cannot reach the private -# tooling, so this encodes only the generic net: internal-infra references, -# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages. -# No project-specific reverse-engineering vocabulary lives here. - +# Thin caller → the canonical reusable guard in freemkv/.github. on: [push, pull_request] - +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: leak-guard: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - name: Compute commit range - id: range - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - base="${{ github.event.pull_request.base.sha }}" - head="${{ github.event.pull_request.head.sha }}" - echo "range=$base..$head" >> "$GITHUB_OUTPUT" - else - before="${{ github.event.before }}" - after="${{ github.sha }}" - # New branch / first push: github.event.before is all-zeros. - if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then - echo "range=$after" >> "$GITHUB_OUTPUT" - else - echo "range=$before..$after" >> "$GITHUB_OUTPUT" - fi - fi - - name: Run leak-guard - run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}" + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork + uses: freemkv/.github/.github/workflows/guard-leak.yml@v1 diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 942f71e..ae153f8 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -13,6 +13,7 @@ name: qa # Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its # dependencies from dev tips would be validating a combination that is not the # one being released, which is the exact failure this branch exists to prevent. +# The shared checkout-siblings composite resolves qa->qa from the caller branch. # # What this gate CANNOT cover: `disc://` and real `iso://` need physical media, # and no hosted runner has an optical drive or the image hoard. Those run on a @@ -23,6 +24,11 @@ on: branches: [qa] workflow_dispatch: +# Supersede an in-progress qa run when a newer candidate lands on the branch. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT # the MSRV read needs.resolve-msrv.outputs.v from Cargo.toml's rust-version; @@ -32,7 +38,7 @@ jobs: outputs: v: ${{ steps.msrv.outputs.v }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 - id: msrv shell: bash run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT" @@ -56,7 +62,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Stamp the next rc @@ -85,28 +91,14 @@ jobs: os: ['ubuntu-latest', 'macos-latest'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: qa - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: qa - path: freemkv-unlock - - name: Resolve siblings from their qa tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} @@ -122,28 +114,14 @@ jobs: cross-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: qa - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: qa - path: freemkv-unlock - - name: Resolve siblings from their qa tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -162,28 +140,14 @@ jobs: needs: resolve-msrv runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 with: path: freemkv-keysources - - uses: actions/checkout@v7 + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 with: - repository: freemkv/libfreemkv - ref: qa - path: libfreemkv - - uses: actions/checkout@v7 - with: - repository: freemkv/freemkv-unlock - ref: qa - path: freemkv-unlock - - name: Resolve siblings from their qa tips - shell: bash - working-directory: freemkv-keysources - run: | - mkdir -p .cargo - cat > .cargo/config.toml <<'EOF' - [patch.crates-io] - libfreemkv = { path = "../libfreemkv" } - EOF + checkout: libfreemkv freemkv-unlock + patch: libfreemkv + self: freemkv-keysources - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ needs.resolve-msrv.outputs.v }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa46851..3314185 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 - name: Verify Cargo.toml version matches tag run: | CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')" @@ -41,7 +41,7 @@ jobs: needs: verify runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 - id: msrv shell: bash run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT" @@ -64,7 +64,7 @@ jobs: needs: verify runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: diff --git a/ci/comment-guard.py b/ci/comment-guard.py deleted file mode 100644 index d52dbe0..0000000 --- a/ci/comment-guard.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env python3 -"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility. - -Three tiers, keyed on the syntax the author reached for: - - * `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per - file, at the top, documenting the module as a whole, so it can't be used to - smuggle per-item rationale the way item docs can. Long correctness / design - essays (e.g. a memory-ordering proof) legitimately live here. - * `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced - ``` code blocks do NOT count, so real examples aren't punished. This holds - whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc - paragraph just like a `pub` one. Capping `///` here (rather than exempting - it, as the old guard did — issue #17) is what stops rationale being hidden - behind a doc sigil, while still leaving room for a genuine paragraph. - * `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the - shape that sprawls into walls, so it's kept tight: stanzas separated by a - single blank line count CUMULATIVELY, so one essay can't be split into runs. - -Overflow belongs in the module `//!`, or in docs/.md with a one-line `//` -pointer — both show up in review. - -Scans src/ and tests/ under the given root (default cwd). `--selftest` runs -built-in cases. Exit non-zero if any block exceeds its cap. -""" -import re -import sys -from pathlib import Path - -INLINE_CAP = 3 -DOC_CAP = 8 - -_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## … - - -def _strip_line_comment(s): - """Return (kind, body) for a whole-line comment, else (None, None). - - kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on - a code line is not a whole-line comment and is ignored (single-line, cheap). - """ - if s.startswith("//!"): - return "doc", s[3:] - if s.startswith("///") and not s.startswith("////"): - return "doc", s[3:] - if s.startswith("//"): - return "plain", s[2:] - return None, None - - -def _fence_toggle(body): - """True if this doc body line opens/closes a ``` fenced code block.""" - return body.strip().startswith("```") - - -def _classify(lines): - """Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of - block comments, and code info. Block comments are tracked across lines. - """ - info = [] - in_block = False - block_is_doc = False - raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#' - for raw in lines: - s = raw.lstrip() - rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False} - # Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the - # bytes are string data, not Rust comments, so the guard is blind to them - # until the string closes. Prevents `/* */` or `//` lines in an embedded - # page from being counted as comment blocks. - if raw_hashes is not None: - if ('"' + "#" * raw_hashes) in raw: - raw_hashes = None - info.append(rec) - continue - if in_block: - rec["comment"] = "doc" if block_is_doc else "plain" - rec["is_doc"] = block_is_doc - if "*/" in raw: - in_block = False - info.append(rec) - continue - # A block comment only starts a comment-line if it's the first token. - if s.startswith("/*"): - rec["is_doc"] = s.startswith("/**") or s.startswith("/*!") - rec["comment"] = "doc" if rec["is_doc"] else "plain" - if "*/" not in s[2:]: - in_block = True - block_is_doc = rec["is_doc"] - info.append(rec) - continue - kind, _ = _strip_line_comment(s) - if kind is not None: - rec["comment"] = kind - rec["is_doc"] = kind == "doc" - info.append(rec) - continue - # Code (or blank). Blank = empty after strip. - rec["code"] = s != "" - # A code line may OPEN a raw string that spans following lines. Detected - # only on code (comments were handled above), so `// see r"x"` is safe. - m = _RAW_OPEN.search(raw) - if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]: - raw_hashes = len(m.group(1)) - info.append(rec) - return info - - -def violations(path): - lines = path.read_text(encoding="utf-8", errors="replace").splitlines() - info = _classify(lines) - n = len(info) - out = [] - - i = 0 - while i < n: - rec = info[i] - if rec["comment"] is None: - i += 1 - continue - - start = i - if rec["is_doc"]: - # Doc run: contiguous doc lines. `//!` / `/*!` module docs are - # UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at - # DOC_CAP, regardless of the documented item's visibility. - j = i - prose = 0 - in_fence = False - module_doc = False - while j < n and info[j]["comment"] == "doc": - s = info[j]["s"] - if s.startswith("//!") or s.startswith("/*!"): - module_doc = True - body = ( - s[3:] if (s.startswith("///") or s.startswith("//!")) else s - ) - if _fence_toggle(body): - in_fence = not in_fence - elif not in_fence and body.strip() != "": - prose += 1 - j += 1 - if not module_doc and prose > DOC_CAP: - out.append((start + 1, prose, DOC_CAP, "doc prose")) - i = j - continue - - # Internal (plain) run, with single-blank bridging (cumulative). - j = i - count = 0 - while j < n: - r = info[j] - if r["comment"] == "plain": - count += 1 - j += 1 - continue - if r["comment"] == "doc": - break - if r["comment"] is None and not r["code"]: - # blank: bridge only a SINGLE blank, else stop - if j + 1 < n and info[j + 1]["comment"] == "plain": - j += 1 - continue - break - break # code - if count > INLINE_CAP: - out.append((start + 1, count, INLINE_CAP, "inline comment")) - i = j - return out - - -def main(argv): - if "--selftest" in argv: - return _selftest() - root = Path(argv[1]) if len(argv) > 1 else Path.cwd() - files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs")) - total = 0 - for f in files: - for start, length, cap, kind in violations(f): - rel = f.relative_to(root) - print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})") - total += 1 - if total: - print(f"\ncomment-guard: {total} comment block(s) exceed their cap.") - print( - f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at " - f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. " - "Trim, move the rationale into the module //!, or docs/.md " - "with a // pointer." - ) - return 1 - return 0 - - -def _selftest(): - import tempfile - - cases = [ - # (rust source, expected number of violations) - # short /// doc -> ok - ("/// short doc\npub fn a() {}\n", 0), - # /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine - ("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0), - # ...but /// is NOT unlimited: 9 prose lines on a private item fails - ("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1), - # 8 prose /// on a pub item -> ok (boundary) - ("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0), - # 9 prose /// on a pub item -> violation - ("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1), - # /// with a long fenced example -> example excluded, ok - ( - "/// summary\n/// # Examples\n/// ```\n" - + "".join("/// code\n" for _ in range(12)) - + "/// ```\npub fn e() {}\n", - 0, - ), - # pub(crate) gets the SAME doc cap as pub (no pub/private split anymore) - ("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0), - # bare inline 4 lines -> violation - ("// a\n// b\n// c\n// d\nlet x = 1;\n", 1), - # blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation - ("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1), - # plain /* */ block, 5 lines -> inline cap -> violation - ("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1), - # comment-like lines INSIDE a raw string are string data, not comments - ( - 'const H: &str = r##""##;\nfn h() {}\n', - 0, - ), - # //! module doc may run long -> EXEMPT (no cap) - ("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0), - # /*! block module doc may run long -> EXEMPT - ("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0), - ] - ok = True - with tempfile.TemporaryDirectory() as d: - for idx, (src, want) in enumerate(cases): - p = Path(d) / f"case{idx}.rs" - p.write_text(src) - got = len(violations(p)) - flag = "ok" if got == want else "FAIL" - if got != want: - ok = False - print(f" selftest {idx}: want {want} got {got} [{flag}]") - print("selftest PASSED" if ok else "selftest FAILED") - return 0 if ok else 1 - - -if __name__ == "__main__": - sys.exit(main(sys.argv)) diff --git a/ci/leak-guard.sh b/ci/leak-guard.sh deleted file mode 100755 index b44cb91..0000000 --- a/ci/leak-guard.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -# -# leak-guard.sh — self-contained public-repo leak gate. -# -# This is the LAST line of defense in CI. It is intentionally self-contained: -# public CI cannot reach the private tooling, so this script encodes ONLY the -# generic net — internal infrastructure references, agent-context files, and -# AI-attribution in commit messages. It deliberately contains NO project- -# specific reverse-engineering vocabulary (those words would themselves be a -# leak). The richer private scanner stays private. -# -# Fails (exit 1) if any of the following appear in the repo: -# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public), -# 2. tracked file content matching the internal-infra net, -# 3. a commit message (in the given range) with AI attribution. -# -# Usage: -# leak-guard.sh [] -# optional git rev-list range to scan commit messages -# (e.g. "abc..def"). If omitted, commit-message scan is -# skipped (path + content checks always run). - -set -euo pipefail - -# Absolute path to this script, resolved before any cd, so we can exclude it -# from the content scan (it necessarily contains the detection patterns). -SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" - -REPO="$(git rev-parse --show-toplevel)" -cd "$REPO" - -fail=0 -note() { printf ' ✗ %s\n' "$1"; fail=1; } - -# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so -# the patterns themselves must not name any org-specific identifier (doing so -# would itself leak the infra they guard). We catch the leak *class*: -# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16), -# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid), -# - docker.internal. -# The full org-specific net (literal hostnames, service names, repo paths, -# vendor tooling, …) lives ONLY in the private scanner and never ships here. -INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal' -# Home-path net — GENERIC ONLY. Catches an absolute developer home path -# committed into a tracked file (a macOS /Users//… or Linux /home//… -# path). This names NO specific user — it matches the leak *class* (any home -# path), so the pattern itself reveals nothing org- or person-specific. A real -# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips -# this regardless of whose machine it came from. The username segment is a -# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build -# the user at runtime — shell `/home/$USER/`, doc `/home//`, Rust -# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks. -HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/' -# AI-attribution net (case-insensitive). "claude" matches only as a standalone -# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit -# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive. -ATTR_RE='co-authored-by|generated with|🤖|(?) { if ($l =~ /$rx/) { print "$.: $&\n"; } } - ' "$1" "$2" 2>/dev/null -} - -# This script's own source necessarily contains the detection patterns (e.g. -# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it. -SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)" - -echo "── leak-guard: internal-infra references in tracked files ──" -while IFS= read -r f; do - case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac - [ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue - [ -f "$f" ] || continue - while IFS= read -r hit; do - [ -z "$hit" ] && continue - note "internal-infra reference: $f:$hit" - done < <(pcre_matches "$f" "$INFRA_RE") - while IFS= read -r hit; do - [ -z "$hit" ] && continue - note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)" - done < <(pcre_matches "$f" "$HOMEPATH_RE") -done < <(git ls-files) - -RANGE="${1:-}" -if [ -n "$RANGE" ]; then - echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──" - while IFS= read -r sha; do - [ -z "$sha" ] && continue - msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)" - # Pass the pattern as an argument (not interpolated into a //) so the - # lookbehind char class and "/" don't break the regex. - hit="$(printf '%s' "$msg" | perl -e ' - my $re = $ARGV[0]; my $rx = qr/$re/i; - while (my $l = ) { if ($l =~ /($rx)/) { print "$1\n"; last; } } - ' "$ATTR_RE" | head -1 || true)" - [ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)" - done < <(git rev-list "$RANGE" 2>/dev/null || true) -fi - -echo -if [ "$fail" -ne 0 ]; then - echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH" - exit 1 -fi -echo "✓ leak-guard: clean"