diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index a15a696..0e9bbfa 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -53,12 +53,12 @@ jobs: - name: Checkout consumer repo # In a reusable workflow the github context belongs to the CALLER, so a # bare checkout lands the consumer repo at the triggering ref. - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 (node24) with: path: repo - name: Set up Python 3.12 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 (node24) with: python-version: "3.12" @@ -74,13 +74,50 @@ jobs: # grant is ever removed this step fails loudly with a 404 — the fix is # to restore the grant or pass a fine-grained PAT via the caller's # `secrets: inherit`. - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 (node24) with: repository: BonfireAI/candyfactory-quality ref: ${{ github.job_workflow_sha }} path: .cf-quality token: ${{ github.token }} + - name: Honor the consumer's declared kit pin (fail-safe — never floats) + # `github.job_workflow_sha` above is DOCUMENTED as the reusable + # workflow's commit but was OBSERVED empty at run time (notioncrm run + # 27370755155: the checkout received no `ref:` and fell back to + # `git checkout -B main refs/remotes/origin/main`), so SHA-pinned + # consumers were gauged by whatever kit MAIN carried — counts + # irreproducible, twice in one night. The pin of record is the one + # the consumer COMMITS in its caller stub; this step re-anchors the + # kit checkout to that SHA before anything is installed, and REFUSES + # to run when no pin can be determined — the default fails safe + # instead of silently floating to main. + working-directory: ${{ github.workspace }}/.cf-quality + env: + JOB_WORKFLOW_SHA: ${{ github.job_workflow_sha }} + run: | + HEAD_SHA="$(git rev-parse HEAD)" + mapfile -t PINS < <(grep -rhoE 'BonfireAI/candyfactory-quality/\.github/workflows/quality-gate\.yml@[0-9a-f]{40}' "$GITHUB_WORKSPACE/repo/.github/workflows" 2>/dev/null | sed 's/.*@//' | sort -u) + if [ "${#PINS[@]}" -eq 1 ]; then + PIN="${PINS[0]}" + if [ "$HEAD_SHA" = "$PIN" ]; then + echo "kit checkout already rides the declared pin $PIN" + else + echo "::warning::kit checkout was at $HEAD_SHA (github.job_workflow_sha='$JOB_WORKFLOW_SHA') — re-anchoring to the consumer's declared pin" + git fetch --depth 1 origin "$PIN" + git checkout --detach "$PIN" + echo "kit re-anchored to the declared pin $PIN" + fi + elif [ "${#PINS[@]}" -gt 1 ]; then + echo "::error::quality-gate refused: ${#PINS[@]} distinct kit pins committed in the consumer's workflows (${PINS[*]}) — one pin of record only" + exit 1 + elif [ -n "$JOB_WORKFLOW_SHA" ] && [ "$HEAD_SHA" = "$JOB_WORKFLOW_SHA" ]; then + echo "no committed caller pin found; kit checkout rides github.job_workflow_sha $HEAD_SHA" + else + echo "::error::quality-gate refused: no full-SHA kit pin committed in the consumer's workflows and github.job_workflow_sha is unreliable ('$JOB_WORKFLOW_SHA') — a gate floating on kit MAIN gauges nothing reproducible; pin the caller stub per templates/quality.caller.yml" + exit 1 + fi + - name: Install the gauge kit + pinned tool battery # [dev] carries ruff/mypy/mypy-baseline/pytest/complexipy — one gauge # block; the consumer never supplies its own copies of the gate tools. @@ -98,9 +135,11 @@ jobs: run: | CF_SOURCE_ROOT="$(cf-repo-config source-root)" CF_PACKAGE_DIR="$(cf-repo-config package-dir)" + CF_FIRST_PARTY="$(cf-repo-config first-party)" echo "CF_SOURCE_ROOT=$CF_SOURCE_ROOT" >> "$GITHUB_ENV" echo "CF_PACKAGE_DIR=$CF_PACKAGE_DIR" >> "$GITHUB_ENV" - echo "resolved source root: $CF_SOURCE_ROOT / package dir: $CF_PACKAGE_DIR" + echo "CF_FIRST_PARTY=$CF_FIRST_PARTY" >> "$GITHUB_ENV" + echo "resolved source root: $CF_SOURCE_ROOT / package dir: $CF_PACKAGE_DIR / first-party: $CF_FIRST_PARTY" - name: Install the consumer package # Monorepo layouts (declared package_dir) install from the package's @@ -134,7 +173,13 @@ jobs: # Same doctrine as mypy below: the config the verdict is graded by # comes from the kit checkout at the pinned SHA, so a consumer cannot # weaken the gauge it is graded by (hollow-vendored-copy refusal). - run: ruff check . --config "$GITHUB_WORKSPACE/.cf-quality/configs/ruff-base.toml" + # known-first-party is DERIVED from the consumer's resolved source + # root (cf-repo-config first-party): a shared gauge file cannot name + # every consumer's packages, and ruff's on-disk detection mis-files + # first-party imports that do not resolve — the kit's I001 verdict + # then contradicts the repo's legacy isort config. The inline value + # is derived from committed state, never a caller knob. + run: ruff check . --config "$GITHUB_WORKSPACE/.cf-quality/configs/ruff-base.toml" --config "lint.isort.known-first-party=$CF_FIRST_PARTY" - name: ruff format --check (the kit's pinned gauge) run: ruff format --check . --config "$GITHUB_WORKSPACE/.cf-quality/configs/ruff-base.toml" @@ -191,6 +236,26 @@ jobs: echo "::notice::mypy ratchet SKIPPED — no Python files in this repo" fi + - name: complexipy through the snapshot ratchet (a Python repo MUST carry the watermark) + # Cognitive complexity rides the committed complexipy-snapshot.json + # watermark: a plain run compares against the snapshot in CWD — no + # separate ratchet flag exists in 5.5.0; comparison is the default + # (docs/tool-spikes.md). NEVER piped: piping masks the exit code. + # Same presence doctrine as the mypy baseline: the conventions define + # this as a gate, and green-by-missing-file is opt-in gaming — a repo + # with Python REFUSES to pass without its snapshot; only a + # Python-free repo may skip, visibly. The snapshot does NOT + # auto-shrink: re-snapshot on merge stays the runbook's job. + run: | + if [ -f complexipy-snapshot.json ]; then + complexipy "$CF_SOURCE_ROOT" + elif find . -name '*.py' -not -path '*/.*' | grep -q .; then + echo "::error::complexipy ratchet REFUSED — Python files present but no complexipy-snapshot.json; boot the snapshot per configs/BASELINE-CONVENTIONS.md (complexipy --snapshot-create from the repo root), even when clean" + exit 1 + else + echo "::notice::complexipy ratchet SKIPPED — no Python files in this repo" + fi + - name: pytest # Plain pytest: a repo with zero collected tests exits 5 and FAILS the # floor on purpose — a floor without tests is prose, not a gate. Runs diff --git a/.github/workflows/self-ci.yml b/.github/workflows/self-ci.yml index f136c72..5a68765 100644 --- a/.github/workflows/self-ci.yml +++ b/.github/workflows/self-ci.yml @@ -21,10 +21,10 @@ jobs: timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 (node24) - name: Set up Python 3.12 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 (node24) with: python-version: "3.12" @@ -67,5 +67,11 @@ jobs: - name: mypy (zero baseline — the new repo owes 0 type errors, bare) run: mypy src + - name: complexipy — the kit's own cognitive-complexity watermark + # The kit ratchets FIRST (it submits before it preaches): a plain run + # compares against the committed complexipy-snapshot.json in CWD; + # never piped (piping masks the exit code — docs/tool-spikes.md). + run: complexipy src + - name: pytest run: pytest diff --git a/DESIGN.md b/DESIGN.md index 44b4d34..0dcba47 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -73,7 +73,15 @@ Full text in `docs/sha-pin-doctrine.md`; load-bearing points: exact commit the caller pinned the reusable workflow to — so the gate scripts and the workflow file are always the SAME commit. No gauge/workflow skew, and a consumer cannot be gated by scripts newer or older than the - workflow that invokes them. + workflow that invokes them. **Hardened 2026-06-11:** the context value was + OBSERVED empty at run time (the kit checkout received no `ref:` and + actions/checkout floated to the default branch — SHA-pinned consumers were + gauged by kit MAIN, counts irreproducible). The gate now re-anchors the kit + checkout to the pin the consumer COMMITTED in its caller stub, refuses two + distinct pins, and refuses to run at all when no pin is determinable — + fail-safe, never a silent float. Proven by a functional control rod in + `tests/test_workflows.py` (a real git fixture whose floating checkout must + be moved to the declared pin). - Dependabot (`github-actions` ecosystem) owns pin freshness via bump PRs; the bump itself runs through the gate it bumps. Org Actions policy enforces full-SHA pinning; the Constable's weekly sweep audits unpinned refs @@ -419,7 +427,9 @@ ordinary PR. - **complexipy — set-like per function** (new offender fails; baselined offender worsening fails), but the snapshot does NOT auto-shrink: the re-snapshot-on-merge step is runbook procedure (§3 step 6), not yet CI - mechanism, and the complexipy step is not yet in `quality-gate.yml`. + mechanism. The complexipy step IS in `quality-gate.yml` (and `self-ci.yml`) + as of 2026-06-11, with the mypy-style presence rule: a Python repo without + its committed snapshot FAILS; only a Python-free repo skips, visibly. - **cf-exemptions — count-based by design**, and honestly so: `frozen_count` refuses silent growth, but a 1-for-1 entry swap at equal count is machine-visible only as an `exemptions.json` diff in review, not @@ -510,9 +520,10 @@ Everything below is a known gap, on the record. Ordered by blood. wire live today. 6. **Release tag guard** — the plan's floor carries "release refuses tag != pyproject.version"; no such step exists in either workflow yet. -7. **complexipy in CI (§4.11)** — the watermark is runbook-only; no workflow - step, and re-snapshot-on-merge is manual. Improvements are not locked in - mechanically. +7. **complexipy re-snapshot (§4.11)** — the gate step shipped 2026-06-11 + (snapshot-presence rule included), but re-snapshot-on-merge stays manual: + improvements are not locked in mechanically until a merge-time re-snapshot + exists. 8. **ruff-sync check (§4.9)** — not installed, not a step; vendored-gauge content fidelity is unforced unless the consumer mirror-declares it. 9. **Required-status-check mounting + canary (§4.8)** — rulesets and the @@ -589,10 +600,57 @@ unknown keys fail typed (a typo'd key silently ignored would be drift). Absent declaration keeps the historical discovery exactly — `src/` when present, else the repo root — and `mypy-baseline.txt` stays root-anchored. +**cf-exemptions honors the declaration (2026-06-11).** The mexxa main-green +pass measured the gap: cf-exemptions discovered the repo-root `src/` (JS in +mexxa's case) and ignored the declared `source_root`, so every registered +exemption was documentation-grade — the scanner never visited the files the +entries covered. The scan surface now resolves through `cf-repo-config` +exactly like the gauges (typed failure on an invalid declaration), with the +control rod pinned in `tests/test_exemptions.py`: an unregistered +suppression under a declared `server/src` MUST fail. + +**known-first-party rides the declared layout too (2026-06-11).** The shared +ruff gauge cannot statically name consumer packages, and detection mis-files +first-party imports that do not resolve on disk — `cf-repo-config +first-party` derives the names from the resolved source root and the gate +passes them inline to ruff (committed state, never a caller knob). + Honest residue: a declared root holding one trivial `.py` while the real code lives elsewhere passes the resolver — the floor is non-emptiness, not completeness; review and the repo-root-wide gates (ruff, file-budget, -exemptions, recursion at `.`) still measure the whole tree. +recursion at `.`) still measure the whole tree. + +## 7. The client membrane (client repos receive gate-config only) + +The canon's client-membrane rule (ADR 0030 §11) and the gate contradicted +each other: client repos must NOT carry the sticky/chrome, but +`cf-sticky-check` failed `STICKY_CLAUDE_MD_MISSING` with no waiver knob +(zero-inputs doctrine), so a client repo's gate could never go fully green — +the doctrine forbade the very file the gate demanded. + +**The mechanism.** The waiver is COMMITTED state, not a knob: +`[tool.cf-quality] client_repo = true` (same homes and validation as the +layout keys — a non-boolean value fails `GATE_CONFIG_INVALID`, typed, never +coerced). Three control rods, each pinned in `tests/test_sticky_check.py`: + +- **R1** — declared client + no CLAUDE.md: the check passes and the CLI + prints the loud membrane notice ("client membrane declared — sticky intro + not mounted per ADR 0030 §11") — waived visibly, never silently. +- **R2** — no declaration: behavior unchanged for the fleet, byte for byte; + `client_repo = false` behaves exactly as undeclared. +- **R3** — declared client carrying ANY sticky chrome (the canonical block, + a chewed or older-vintage copy, or the kit's mount-marker header) fails + `STICKY_CLIENT_MEMBRANE_BREACHED` — the membrane is two-way. `mount` + refuses a declared client repo outright (`STICKY_MOUNT_CLIENT_MEMBRANE`). + +**Honest residue.** The gate cannot verify WHO is a client: a non-client +repo could commit the declaration and dodge the sticky mount. The defense is +the same one every doctrine surface carries — the declaration is a reviewed +diff (Wizard-gated adoption), and the notice prints on EVERY gate run, so a +wrongly-declared repo is loud in its own CI logs. A Constable-cadence sweep +of `client_repo` declarations against the known client list is the candidate +mechanical closure. Prose mentioning the law is NOT chrome (the breach keys +on the heading line, the mount marker, and block near-match — not keywords). --- diff --git a/complexipy-snapshot.json b/complexipy-snapshot.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/complexipy-snapshot.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/configs/ruff-base.toml b/configs/ruff-base.toml index a1e14c9..fd174c7 100644 --- a/configs/ruff-base.toml +++ b/configs/ruff-base.toml @@ -10,6 +10,17 @@ # line-length 100 · target py312 # Consumer mounts: copy this file to /ruff.toml (or `extend` it) and add # a ruff-sync stanza pointing back at this kit's pinned SHA. +# +# known-first-party (isort) is deliberately NOT set here: a shared gauge file +# cannot name every consumer's packages, and ruff's on-disk detection mis-files +# first-party imports that do not resolve on disk (modules authored RED, tests +# addressed by package path) — measured on a consumer where fixing the kit's +# I001 created NEW legacy-isort I001. The gate DERIVES the names per consumer +# (`cf-repo-config first-party`, from the resolved source root) and passes them +# inline: `--config "lint.isort.known-first-party=[...]"`. A vendored local +# copy MAY add [lint.isort] known-first-party with the repo's own names for +# editor/pre-commit parity — like per-file-ignores, it cannot weaken the graded +# verdict (CI derives its own value from committed state). line-length = 100 target-version = "py312" diff --git a/docs/sha-pin-doctrine.md b/docs/sha-pin-doctrine.md index b327b78..e2caf7b 100644 --- a/docs/sha-pin-doctrine.md +++ b/docs/sha-pin-doctrine.md @@ -1,7 +1,7 @@ # SHA-pin doctrine 1. Every cross-repo workflow/action ref is pinned to a full commit SHA (40-hex) — never `@main`, never a tag (tags move; SHAs do not). -2. The caller stub pins `quality-gate.yml@`; the gate checks the kit out at `github.job_workflow_sha`, so gauge scripts and workflow are always the same commit. +2. The caller stub pins `quality-gate.yml@`; the gate checks the kit out at `github.job_workflow_sha` and RE-ANCHORS to the consumer's committed pin (the context value was observed EMPTY at run time, silently floating the checkout to kit main) — when no pin is determinable the gate refuses, never floats. 3. Org Actions policy enforces full-SHA pinning, so an unpinned ref cannot even be merged. 4. Dependabot (`github-actions` ecosystem, weekly) owns pin freshness: it opens pin-bump PRs in every consumer repo, so the pinned gauge cannot rot. 5. A pin-bump PR is reviewed and merged by Anta like any change — the bump itself runs through the gate it bumps. diff --git a/src/cf_quality/exemptions.py b/src/cf_quality/exemptions.py index 046eaa3..41535ac 100644 --- a/src/cf_quality/exemptions.py +++ b/src/cf_quality/exemptions.py @@ -36,10 +36,14 @@ or ``scripts/check_host_free.py`` the gate runs them and propagates their exit codes; absent scripts are skipped silently. -The measured surface is ``/src`` when it exists; otherwise the layout -is DISCOVERED (top-level packages/modules outside tests/docs/scripts), so a -flat or app layout is measured, never a silent no-op (the refuter's -src-only escape). +The measured surface is the committed ``[tool.cf-quality] source_root`` when +declared — resolved through cf-repo-config exactly like the gauges (the +mexxa main-green pass proved the old behavior: a JS repo-root ``src/`` was +discovered, the declared ``server/src`` never visited, and every registered +exemption was documentation-grade). Absent a declaration, ``/src`` +when it exists; otherwise the layout is DISCOVERED (top-level +packages/modules outside tests/docs/scripts), so a flat or app layout is +measured, never a silent no-op (the refuter's src-only escape). Comments are read via :mod:`tokenize` (COMMENT tokens only), so suppression text inside string literals never trips the gate. @@ -58,6 +62,7 @@ from pathlib import Path from typing import Any +from cf_quality import repo_config from cf_quality.errors import GateError, GateViolation GATED_NOQA_RULES = frozenset({"C901", "PLR0915"}) @@ -71,21 +76,10 @@ GATED_TYPE_IGNORE_CODES = frozenset({"override"}) FOLD_IN_SCRIPTS = ("check_english.py", "check_host_free.py") #: Directories never measured for suppressions (tests carry their own -#: per-file-ignores discipline; the rest is non-shipping surface). -EXCLUDED_SCAN_DIRS = frozenset( - { - "tests", - "test", - "docs", - "examples", - "scripts", - "__pycache__", - "node_modules", - "build", - "dist", - "venv", - } -) +#: per-file-ignores discipline; the rest is non-shipping surface). The set +#: itself lives in repo_config — one gauge-block, shared with the +#: first-party derivation, never two lists drifting apart. +EXCLUDED_SCAN_DIRS = repo_config.NON_SHIPPING_DIRS REQUIRED_ENTRY_KEYS = ("file", "symbol_or_line", "rule", "reason", "approver") _NOSEC_RE = re.compile(r"#\s*nosec\b(.*)") @@ -226,11 +220,16 @@ def _is_gated_code(code: str) -> bool: def _discover_scan_paths(root: Path) -> list[Path]: - """``src/`` when present; otherwise every top-level Python location. + """The declared ``source_root`` when committed; else ``src/`` when + present; otherwise every top-level Python location. - A flat or app layout is measured, never a silent no-op: top-level - ``*.py`` files and every non-excluded directory holding Python count. + A declared layout is honored exactly like the gauges honor it (typed + failure on an invalid declaration, never a silent fallback). A flat or + app layout is measured, never a silent no-op: top-level ``*.py`` files + and every non-excluded directory holding Python count. """ + if repo_config.load(root).source_root is not None: + return [repo_config.resolve_source_root(root)] src = root / "src" if src.is_dir(): return [src] diff --git a/src/cf_quality/repo_config.py b/src/cf_quality/repo_config.py index ca31497..4dc4724 100644 --- a/src/cf_quality/repo_config.py +++ b/src/cf_quality/repo_config.py @@ -9,6 +9,8 @@ [tool.cf-quality] source_root = "server/src" # the tree mypy gates (and gate defaults) package_dir = "server" # where the installable package lives + client_repo = true # the client membrane: gate-config only, + # never sticky/chrome (ADR 0030 §11) Anti-gaming (the empty-room rule): a DECLARED ``source_root`` must exist, stay inside the repo, and hold at least one ``.py`` file — pointing the gauge @@ -33,15 +35,41 @@ from cf_quality.errors import GateError CONFIG_FILENAME = ".cf-quality.toml" -_ALLOWED_KEYS = frozenset({"source_root", "package_dir"}) +_ALLOWED_KEYS = frozenset({"source_root", "package_dir", "client_repo"}) + +#: Directory names that are never first-party import surface when the source +#: root is the repo root itself (tests carry their own per-file-ignores +#: discipline; the rest is non-shipping). cf-exemptions shares this set for +#: its scan discovery — one gauge-block, never two lists drifting apart. +NON_SHIPPING_DIRS = frozenset( + { + "tests", + "test", + "docs", + "examples", + "scripts", + "__pycache__", + "node_modules", + "build", + "dist", + "venv", + } +) @dataclass(frozen=True) class RepoConfig: - """The committed ``[tool.cf-quality]`` declaration; both fields optional.""" + """The committed ``[tool.cf-quality]`` declaration; all fields optional. + + ``client_repo`` is the client-membrane declaration: a client repo + receives gate-config only — never sticky/chrome. Declaring it is a + Wizard-gated, reviewed act (committed state riding a PR), and the gates + honor it LOUDLY, never silently. + """ source_root: str | None = None package_dir: str | None = None + client_repo: bool = False def _config_error(message: str, context: dict[str, object]) -> GateError: @@ -81,6 +109,20 @@ def _string_field(table: dict[str, object], key: str, home: str) -> str | None: return value +def _bool_field(table: dict[str, object], key: str, home: str) -> bool: + """A strict TOML boolean; absent means False. A tampered declaration + ("yes", 1, "true") is no declaration — it fails typed, never coerces.""" + value = table.get(key) + if value is None: + return False + if not isinstance(value, bool): + raise _config_error( + f"[tool.cf-quality] {key} in {home} must be a TOML boolean (true/false)", + {"home": home, "key": key, "value": repr(value)}, + ) + return value + + def load(root: Path) -> RepoConfig: """The committed declaration of one repo; all-absent means 'use discovery'.""" homes = { @@ -107,6 +149,7 @@ def load(root: Path) -> RepoConfig: return RepoConfig( source_root=_string_field(table, "source_root", home), package_dir=_string_field(table, "package_dir", home), + client_repo=_bool_field(table, "client_repo", home), ) @@ -158,29 +201,61 @@ def resolve_package_dir(root: Path) -> Path: return declared +def first_party_packages(root: Path) -> list[str]: + """The consumer's own top-level import names, derived from its source root. + + The shared ruff gauge cannot statically name every consumer's packages, + and ruff's on-disk detection mis-files first-party imports that do not + resolve on disk (a module authored RED before it exists, or tests + addressed by package path) — the kit's I001 verdict then contradicts the + consumer's legacy ``known-first-party``. The gate therefore derives the + names from the consumer's OWN resolved source root: top-level packages + (any directory holding Python — PEP 420 needs no ``__init__.py``) and + top-level modules. At a repo-root source root the non-shipping + directories are excluded; hidden entries never count. + """ + source_root = resolve_source_root(root) + at_repo_root = source_root.resolve() == root.resolve() + names: set[str] = set() + for child in source_root.iterdir(): + if child.name.startswith(".") or (at_repo_root and child.name in NON_SHIPPING_DIRS): + continue + if child.is_file() and child.suffix == ".py": + names.add(child.stem) + elif child.is_dir() and next(child.rglob("*.py"), None) is not None: + names.add(child.name) + return sorted(names) + + def _relative_form(root: Path, target: Path) -> str: """Root-relative POSIX form for shell consumption ('.' for the root itself).""" return target.resolve().relative_to(root.resolve()).as_posix() +def _resolve_field(root: Path, field: str) -> str: + """One CLI field's stdout form; first-party is a TOML/JSON array literal + so the workflow can splice it into ``lint.isort.known-first-party=...``.""" + if field == "first-party": + return json.dumps(first_party_packages(root)) + target = resolve_source_root(root) if field == "source-root" else resolve_package_dir(root) + return _relative_form(root, target) + + def main(argv: list[str] | None = None) -> int: - """Exit 0 resolved (path on stdout) · 2 invalid declaration (typed, stderr).""" + """Exit 0 resolved (value on stdout) · 2 invalid declaration (typed, stderr).""" parser = argparse.ArgumentParser( prog="cf-repo-config", description="Resolve the consumer repo's declared layout ([tool.cf-quality]).", ) - parser.add_argument("field", choices=("source-root", "package-dir")) + parser.add_argument("field", choices=("source-root", "package-dir", "first-party")) parser.add_argument("--root", default=".", help="repo root (default: cwd)") args = parser.parse_args(argv) - root = Path(args.root) try: - target = ( - resolve_source_root(root) if args.field == "source-root" else resolve_package_dir(root) - ) + resolved = _resolve_field(Path(args.root), args.field) except GateError as error: print(json.dumps(error.to_dict(), ensure_ascii=False), file=sys.stderr) return 2 - print(_relative_form(root, target)) + print(resolved) return 0 diff --git a/src/cf_quality/sticky_check.py b/src/cf_quality/sticky_check.py index ae87847..4ae35d3 100644 --- a/src/cf_quality/sticky_check.py +++ b/src/cf_quality/sticky_check.py @@ -21,6 +21,23 @@ recognizable near-copy of the block — chewed gum (heading included) is never silently duplicated or papered over. +The client membrane (ADR 0030 §11): client repos receive gate-config ONLY — +never sticky/chrome. A repo declaring ``[tool.cf-quality] client_repo = true`` +(committed, Wizard-gated state — see :mod:`cf_quality.repo_config`) is +therefore waived from carrying the block, LOUDLY (the CLI prints the waiver, +never a silent pass), and the membrane is two-way: + +- declared client + no CLAUDE.md (or a CLAUDE.md without chrome) -> pass, + with the visible client-membrane notice; +- declared client + ANY recognizable sticky chrome (the canonical block, + a chewed or older-vintage copy, or the kit's mirror header) fails + ``STICKY_CLIENT_MEMBRANE_BREACHED``; +- ``mount`` refuses a declared client repo (``STICKY_MOUNT_CLIENT_MEMBRANE``) + — the kit never pushes chrome through the membrane; +- an undeclared repo keeps today's behavior exactly, and a tampered + declaration fails typed (``GATE_CONFIG_INVALID``) — the waiver cannot be + adopted by accident or by a loose string. + The canonical text is loaded from the KIT's own packaged data file (``cf_quality/data/sticky-intro.md``, via :mod:`importlib.resources` so wheel and editable installs resolve identically), never from the consumer repo — @@ -46,12 +63,21 @@ from importlib import resources from pathlib import Path +from cf_quality import repo_config from cf_quality.errors import GateError, GateViolation MIRROR_HEADER = ( "" ) +#: Any vintage of the kit's mount header marks chrome — the v1 header named +#: only ADR 0029, so the breach check keys on the stable mounted-by suffix. +_MOUNT_MARKER = "mounted by candyfactory-quality" +#: The loud waiver notice — printed whenever the membrane is honored. +CLIENT_MEMBRANE_NOTICE = ( + "=== client membrane declared — sticky intro not mounted per ADR 0030 §11 " + "(client repos receive gate-config only, never chrome) ===" +) _DATA_FILENAME = "sticky-intro.md" @@ -203,9 +229,44 @@ def _duplicated(path: str, detail: str) -> GateViolation: ) +def _membrane_violations(claude_md: Path, canonical_lines: list[str]) -> list[GateViolation]: + """The two-way membrane: a declared client repo must NOT carry chrome. + + Chrome is recognized by the canonical heading line, the kit's mount + marker (any vintage — the v1 header differs from today's), or an exact / + near copy of the canonical block in the RAW bytes (a buried copy is + chrome too). Prose merely naming the law is not chrome. + """ + raw_lines = _normalize(claude_md.read_text(encoding="utf-8")).splitlines() + raw_pairs = list(enumerate(raw_lines, start=1)) + heading = canonical_lines[0] + carries_chrome = ( + any(line.strip() == heading for line in raw_lines) + or any(_MOUNT_MARKER in line for line in raw_lines) + or bool(_find_block(canonical_lines, raw_pairs)) + or _near_match_start(canonical_lines, raw_pairs) is not None + ) + if not carries_chrome: + return [] + return [ + GateViolation( + code="STICKY_CLIENT_MEMBRANE_BREACHED", + message=( + "this repo declares client_repo = true but carries sticky chrome — " + "the membrane is two-way: client repos receive gate-config only, " + "never the sticky intro (remove the block or the declaration)" + ), + path=str(claude_md), + ) + ] + + def check(claude_md: Path) -> list[GateViolation]: """Gauge one CLAUDE.md against the kit's canonical sticky block.""" + client = repo_config.load(claude_md.parent).client_repo if not claude_md.is_file(): + if client: + return [] # the membrane waives the mount; the CLI prints it loud return [ GateViolation( code="STICKY_CLAUDE_MD_MISSING", @@ -214,6 +275,8 @@ def check(claude_md: Path) -> list[GateViolation]: ) ] canonical_lines = canonical_text().splitlines() + if client: + return _membrane_violations(claude_md, canonical_lines) raw_lines = _normalize(claude_md.read_text(encoding="utf-8")).splitlines() live = _live_lines(raw_lines) occurrences = _find_block(canonical_lines, live) @@ -257,8 +320,20 @@ def mount(claude_md: Path) -> bool: """Append the canonical block when absent. Returns True iff it wrote. Refuses on ANY recognizable near-copy (heading chewed or not) so a - tampered block is never silently duplicated or papered over. + tampered block is never silently duplicated or papered over; refuses a + declared client repo outright — the kit never pushes chrome through the + client membrane. """ + if repo_config.load(claude_md.parent).client_repo: + raise GateError( + code="STICKY_MOUNT_CLIENT_MEMBRANE", + message=( + "refusing to mount the sticky intro into a declared client repo — " + "client repos receive gate-config only, never sticky/chrome " + "(the client membrane, ADR 0030 §11)" + ), + context={"path": str(claude_md)}, + ) canonical = canonical_text() canonical_lines = canonical.splitlines() existing = _normalize(claude_md.read_text(encoding="utf-8")) if claude_md.is_file() else "" @@ -285,6 +360,8 @@ def _run_check(target: Path) -> int: violations = check(target) for violation in violations: print(json.dumps(violation.to_dict(), ensure_ascii=False)) + if not violations and repo_config.load(target.parent).client_repo: + print(CLIENT_MEMBRANE_NOTICE) # the waiver is loud, never silent return 1 if violations else 0 diff --git a/tests/test_exemptions_source_root.py b/tests/test_exemptions_source_root.py new file mode 100644 index 0000000..1b4edde --- /dev/null +++ b/tests/test_exemptions_source_root.py @@ -0,0 +1,86 @@ +"""cf-exemptions honors the committed ``[tool.cf-quality] source_root``. + +Split from ``test_exemptions.py`` by design (the file budget measures, +review judges): this file carries one coherent unit — the declared-layout +defect and its control rod. + +Kit-gate defect (mexxa main-green pass): cf-exemptions discovered the +repo-root ``src/`` (JS in mexxa's case) and IGNORED the committed +``source_root`` — every registered exemption was documentation-grade because +the scanner never visited the files it covered. The measured tree must +resolve through cf-repo-config exactly like the gauges do. + +Control rod: a repo with ``source_root = "server/src"`` and an unregistered +suppression in ``server/src/`` MUST fail — before this fix it silently +passed. +""" + +from pathlib import Path + +import pytest +from test_exemptions import entry, run_gate, write_exemptions + + +def _monorepo(tmp_path: Path, body: str) -> None: + """Repo-root src/ holds only JS (the mexxa shape); Python lives in server/src.""" + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.js").write_text("const x = 1;\n", encoding="utf-8") + server_src = tmp_path / "server" / "src" + server_src.mkdir(parents=True) + (server_src / "mod.py").write_text(body, encoding="utf-8") + (tmp_path / ".cf-quality.toml").write_text( + '[tool.cf-quality]\nsource_root = "server/src"\n', encoding="utf-8" + ) + + +def test_unregistered_suppression_under_declared_root_fails( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _monorepo(tmp_path, "def gnarly(): # noqa: C901\n return 1\n") + code, _, err = run_gate(tmp_path, capsys) + assert code == 2 # gated suppression + no exemptions.json = typed gate error + assert "GATE_CONFIG_MISSING" in err + + +def test_registered_suppression_under_declared_root_passes( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _monorepo(tmp_path, "def gnarly(): # noqa: C901\n return 1\n") + write_exemptions(tmp_path, [entry("server/src/mod.py", "gnarly", "C901")], frozen_count=1) + code, _, _ = run_gate(tmp_path, capsys) + assert code == 0 + + +def test_unregistered_suppression_with_registry_present_fails( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _monorepo(tmp_path, "def gnarly(): # noqa: C901\n return 1\n") + write_exemptions(tmp_path, [entry("server/src/other.py", "ghost", "C901")], frozen_count=1) + code, out, _ = run_gate(tmp_path, capsys) + assert code == 1 + assert "UNREGISTERED_SUPPRESSION" in out + assert "server/src/mod.py:1" in out + + +def test_declared_root_excludes_undeclared_siblings( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # The declared root IS the measured tree (mirroring the mypy gate): + # a suppression outside it is the legacy surface, not this gate's. + _monorepo(tmp_path, "x = 1\n") + legacy = tmp_path / "legacy" + legacy.mkdir() + (legacy / "old.py").write_text("x = 1 # nosec\n", encoding="utf-8") + code, _, _ = run_gate(tmp_path, capsys) + assert code == 0 + + +def test_invalid_declared_root_fails_typed( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / ".cf-quality.toml").write_text( + '[tool.cf-quality]\nsource_root = "ghost/src"\n', encoding="utf-8" + ) + code, _, err = run_gate(tmp_path, capsys) + assert code == 2 + assert "GATE_CONFIG_INVALID" in err diff --git a/tests/test_gate_hardening.py b/tests/test_gate_hardening.py new file mode 100644 index 0000000..2f3c9f4 --- /dev/null +++ b/tests/test_gate_hardening.py @@ -0,0 +1,256 @@ +"""The 2026-06-11 gate-hardening contract — four measured kit/CI defects. + +Split from ``test_workflows.py`` by design (the file budget measures, review +judges): this file carries one coherent unit — the overnight-burn defects and +their control rods. + +1. **Kit-pin honor** — ``github.job_workflow_sha`` observed EMPTY at run + time; the kit checkout floated to main and consumers were gauged by an + unpinned gauge. Functional rod: the step's script runs against a real git + fixture and MUST re-anchor a floating checkout to the committed pin. +2. **Node-24 action pins** — GitHub forces Node 24 from 2026-06-16; the + deprecated Node-20 SHAs are denylisted (ratchet: forward bumps free, + rollback refused). +3. **complexipy joins the gate** — the watermark was runbook-only; now a + workflow step with the mypy-style presence rule. +4. **first-party isort alignment** — the gate derives known-first-party from + the consumer's resolved source root and feeds it to the pinned ruff gauge. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + +from test_workflows import ( + GATE_PATH, + REPO, + SELF_CI_PATH, + _load, + _run_script, + _steps, + _uses_refs, +) + +# --- the kit-pin honor step (gauges floated to kit MAIN) ----------------------- +# +# Observed defect: `github.job_workflow_sha` is DOCUMENTED as the reusable +# workflow's commit but evaluated EMPTY at run time, so the kit checkout +# received no `ref:` and actions/checkout fell back to the default branch +# (`git checkout -B main refs/remotes/origin/main`) — consumers pinned to a +# SHA were gauged by whatever kit MAIN carried (irreproducible counts, twice +# observed in one night). The pin of record is the one the consumer COMMITS +# in its caller stub; the gate re-anchors to it and REFUSES to float. + + +def _pin_honor_step() -> dict[str, Any]: + steps = _steps(_load(GATE_PATH), "gate") + matches = [s for s in steps if "declared kit pin" in s.get("name", "")] + assert len(matches) == 1, "exactly one pin-honor step" + return matches[0] + + +def test_gate_pin_honor_step_sits_between_kit_checkout_and_install() -> None: + steps = _steps(_load(GATE_PATH), "gate") + names = [s.get("name", "") for s in steps] + checkout_idx = next(i for i, s in enumerate(steps) if "gauge kit" in s.get("name", "")) + honor_idx = next(i for i, n in enumerate(names) if "declared kit pin" in n) + install_idx = next(i for i, n in enumerate(names) if "Install the gauge kit" in n) + assert checkout_idx < honor_idx < install_idx, ( + "the pin must be honored after the kit checkout and BEFORE the kit install — " + "installing a floating kit and re-pinning afterwards would gauge with main's scripts" + ) + + +def test_gate_pin_honor_refuses_to_float_and_fails_safe() -> None: + script = _pin_honor_step().get("run", "") + assert "git fetch" in script and "git checkout" in script, "the step must re-anchor by SHA" + assert script.count("exit 1") >= 2, "no-pin and multi-pin must both refuse (fail-safe)" + assert "::error::" in script, "refusal must be loud" + assert "JOB_WORKFLOW_SHA" in script, "the unreliable context value is named in the verdict" + + +def _run_pin_honor( + workspace: Path, kit_dir: Path, job_workflow_sha: str = "" +) -> subprocess.CompletedProcess[str]: + """Execute the pin-honor step's script exactly as the runner would.""" + script = _pin_honor_step()["run"] + return subprocess.run( + ["bash", "-e", "-c", script], + cwd=kit_dir, + env={ + "PATH": "/usr/bin:/bin", + "GITHUB_WORKSPACE": str(workspace), + "JOB_WORKFLOW_SHA": job_workflow_sha, + }, + capture_output=True, + text=True, + check=False, + ) + + +def _git(cwd: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ) + return proc.stdout.strip() + + +def _pin_fixture(tmp_path: Path) -> tuple[Path, Path, str, str]: + """A kit origin with two commits, a workspace whose kit checkout sits at + main (the observed empty-job_workflow_sha fallback), and a consumer stub. + + Returns (workspace, kit_checkout, pinned_sha, main_sha). + """ + origin = tmp_path / "kit-origin" + origin.mkdir() + _git(origin, "init", "-q", "-b", "main") + (origin / "VERSION").write_text("pinned\n", encoding="utf-8") + _git(origin, "add", "VERSION") + _git(origin, "commit", "-q", "-m", "the pinned commit") + pinned_sha = _git(origin, "rev-parse", "HEAD") + (origin / "VERSION").write_text("main moved on\n", encoding="utf-8") + _git(origin, "add", "VERSION") + _git(origin, "commit", "-q", "-m", "main moved on") + main_sha = _git(origin, "rev-parse", "HEAD") + + workspace = tmp_path / "ws" + workspace.mkdir() + _git(workspace, "clone", "-q", str(origin), str(workspace / ".cf-quality")) + stub_dir = workspace / "repo" / ".github" / "workflows" + stub_dir.mkdir(parents=True) + (stub_dir / "quality.yml").write_text( + "jobs:\n gate:\n uses: BonfireAI/candyfactory-quality" + f"/.github/workflows/quality-gate.yml@{pinned_sha}\n", + encoding="utf-8", + ) + return workspace, workspace / ".cf-quality", pinned_sha, main_sha + + +def test_pin_honor_reanchors_floating_checkout_to_the_declared_pin(tmp_path: Path) -> None: + # The control rod: kit checkout at main, consumer pins an older SHA — + # the step MUST move the checkout to the pin (today's defect gauged main). + workspace, kit_dir, pinned_sha, main_sha = _pin_fixture(tmp_path) + assert _git(kit_dir, "rev-parse", "HEAD") == main_sha # the observed fallback + proc = _run_pin_honor(workspace, kit_dir) + assert proc.returncode == 0, proc.stderr + proc.stdout + assert _git(kit_dir, "rev-parse", "HEAD") == pinned_sha + assert pinned_sha in proc.stdout + + +def test_pin_honor_is_a_noop_when_already_at_the_pin(tmp_path: Path) -> None: + workspace, kit_dir, pinned_sha, _ = _pin_fixture(tmp_path) + _git(kit_dir, "checkout", "-q", "--detach", pinned_sha) + proc = _run_pin_honor(workspace, kit_dir, job_workflow_sha=pinned_sha) + assert proc.returncode == 0, proc.stderr + proc.stdout + assert _git(kit_dir, "rev-parse", "HEAD") == pinned_sha + + +def test_pin_honor_refuses_when_no_pin_is_committed(tmp_path: Path) -> None: + # Fail-safe, never float: no parseable caller pin + unreliable context + # value means the gate cannot know which gauge it is — refuse loudly. + workspace, kit_dir, _, _ = _pin_fixture(tmp_path) + (workspace / "repo" / ".github" / "workflows" / "quality.yml").unlink() + proc = _run_pin_honor(workspace, kit_dir) + assert proc.returncode == 1 + assert "refused" in proc.stdout + + +def test_pin_honor_refuses_two_distinct_pins(tmp_path: Path) -> None: + workspace, kit_dir, _, main_sha = _pin_fixture(tmp_path) + extra = workspace / "repo" / ".github" / "workflows" / "second.yml" + extra.write_text( + "jobs:\n gate:\n uses: BonfireAI/candyfactory-quality" + f"/.github/workflows/quality-gate.yml@{main_sha}\n", + encoding="utf-8", + ) + proc = _run_pin_honor(workspace, kit_dir) + assert proc.returncode == 1 + assert "refused" in proc.stdout + + +def test_pin_honor_accepts_job_workflow_sha_when_it_matches_head(tmp_path: Path) -> None: + # When GitHub DOES populate the context value and the checkout already + # rides it, a consumer with no parseable stub (e.g. a fork of the caller + # shape) is still deterministic — no refusal needed. + workspace, kit_dir, pinned_sha, _ = _pin_fixture(tmp_path) + (workspace / "repo" / ".github" / "workflows" / "quality.yml").unlink() + _git(kit_dir, "checkout", "-q", "--detach", pinned_sha) + proc = _run_pin_honor(workspace, kit_dir, job_workflow_sha=pinned_sha) + assert proc.returncode == 0, proc.stderr + proc.stdout + + +# --- Node-24 action pins (GitHub forces Node 24 on 2026-06-16) ----------------- +# +# The deprecated Node-20 SHAs are denylisted (ratchet style — Dependabot may +# bump FORWARD freely; the gate refuses a roll BACK to the deprecated runtime). +# Observed: notioncrm run 27370755155's deprecation annotation named both. + +_NODE20_DEPRECATED_SHAS = { + "11bd71901bbe5b1630ceea73d27597364c9af683", # actions/checkout v4.2.2 (node20) + "0b93645e9fea7318ecaed2b359559ac225c90a2b", # actions/setup-python v5.3.0 (node20) +} + + +def test_workflows_carry_no_deprecated_node20_action_pins() -> None: + for path in (GATE_PATH, SELF_CI_PATH): + for ref in _uses_refs(_load(path)): + sha = ref.rpartition("@")[2] + assert sha not in _NODE20_DEPRECATED_SHAS, ( + f"{path.name} pins {ref} — a Node-20 action; GitHub forces Node 24 " + "from 2026-06-16 (bump to the Node-24 release of the same action)" + ) + + +# --- complexipy joins the gate (the watermark was runbook-only) ----------------- + + +def test_gate_complexipy_refuses_python_repo_without_snapshot() -> None: + # Same doctrine as the mypy baseline: green-by-file-absence is opt-in + # gaming. A Python repo must carry its snapshot; only a Python-free repo + # may skip, visibly. + script = _run_script(_load(GATE_PATH), "gate") + part = script[script.find("complexipy-snapshot.json") :] + assert "::error::" in part, "a Python repo without a snapshot must fail loudly" + assert "exit 1" in part + assert "::notice::" in part, "the Python-free skip stays visible" + + +def test_gate_complexipy_targets_resolved_source_root_unpiped() -> None: + steps = _steps(_load(GATE_PATH), "gate") + complexipy_steps = [s for s in steps if "complexipy" in s.get("name", "")] + assert len(complexipy_steps) == 1 + run = complexipy_steps[0]["run"] + assert 'complexipy "$CF_SOURCE_ROOT"' in run + for line in run.splitlines(): + if "complexipy" in line and "snapshot" not in line: + assert "|" not in line, "piping complexipy masks its exit code (tool spike)" + + +def test_self_ci_runs_complexipy_against_committed_snapshot() -> None: + script = _run_script(_load(SELF_CI_PATH), "gate") + assert "complexipy src" in script, "the kit ratchets FIRST — its own watermark is gated" + assert (REPO / "complexipy-snapshot.json").is_file(), ( + "the kit's own complexipy snapshot must be committed (boot per BASELINE-CONVENTIONS)" + ) + + +# --- first-party isort alignment (the I001 cross-config conflict) --------------- + + +def test_gate_resolves_first_party_names_for_the_ruff_gauge() -> None: + script = _run_script(_load(GATE_PATH), "gate") + assert "cf-repo-config first-party" in script, "the gate derives first-party names" + assert "CF_FIRST_PARTY" in script + + +def test_gate_ruff_check_rides_derived_known_first_party() -> None: + steps = _steps(_load(GATE_PATH), "gate") + check_steps = [s for s in steps if s.get("run", "").startswith("ruff check")] + assert len(check_steps) == 1 + assert "lint.isort.known-first-party=$CF_FIRST_PARTY" in check_steps[0]["run"] diff --git a/tests/test_repo_config.py b/tests/test_repo_config.py index 7fd2e3c..c182a03 100644 --- a/tests/test_repo_config.py +++ b/tests/test_repo_config.py @@ -22,6 +22,7 @@ from cf_quality.errors import GateError from cf_quality.repo_config import ( + first_party_packages, load, main, resolve_package_dir, @@ -171,6 +172,52 @@ def test_non_string_value_fails_typed(tmp_path: Path) -> None: assert excinfo.value.code == "GATE_CONFIG_INVALID" +# --- client_repo: the membrane declaration (client repos get gate-config only) -- + + +def test_client_repo_defaults_to_false(tmp_path: Path) -> None: + repo = _repo(tmp_path) + assert load(repo).client_repo is False + + +def test_client_repo_true_parses(tmp_path: Path) -> None: + repo = _repo(tmp_path) + _declare(repo, "[tool.cf-quality]\nclient_repo = true\n") + assert load(repo).client_repo is True + + +def test_client_repo_false_is_explicitly_not_a_client(tmp_path: Path) -> None: + repo = _repo(tmp_path) + _declare(repo, "[tool.cf-quality]\nclient_repo = false\n") + assert load(repo).client_repo is False + + +def test_client_repo_non_bool_fails_typed(tmp_path: Path) -> None: + # A tampered declaration ("yes", 1, "true") is not a decision — the + # membrane is Wizard-gated committed state, never a loose string. + repo = _repo(tmp_path) + _declare(repo, '[tool.cf-quality]\nclient_repo = "yes"\n') + with pytest.raises(GateError) as excinfo: + load(repo) + assert excinfo.value.code == "GATE_CONFIG_INVALID" + + +def test_client_repo_integer_fails_typed(tmp_path: Path) -> None: + repo = _repo(tmp_path) + _declare(repo, "[tool.cf-quality]\nclient_repo = 1\n") + with pytest.raises(GateError) as excinfo: + load(repo) + assert excinfo.value.code == "GATE_CONFIG_INVALID" + + +def test_client_repo_combines_with_layout_keys(tmp_path: Path) -> None: + repo = _monorepo(tmp_path) + _declare(repo, '[tool.cf-quality]\nsource_root = "server/src"\nclient_repo = true\n') + config = load(repo) + assert config.client_repo is True + assert config.source_root == "server/src" + + def test_malformed_toml_fails_typed(tmp_path: Path) -> None: repo = _repo(tmp_path) _declare(repo, "[tool.cf-quality\nsource_root = ") @@ -219,3 +266,86 @@ def test_main_exits_two_typed_on_invalid_declaration( assert main(["source-root", "--root", str(repo)]) == 2 err = capsys.readouterr().err assert "GATE_CONFIG_INVALID" in err + + +# --- first-party derivation (the isort alignment defect) ----------------------- +# +# The kit's shared ruff gauge cannot statically name every consumer's packages, +# and ruff's path-based detection mis-files imports that do not resolve on disk +# (bonfire: `bonfire.tests.*` has no src/bonfire/tests, so the kit's gauge +# sorted it third-party while the repo's legacy `known-first-party = ["bonfire"]` +# holds it first-party — fixing the kit's I001 created NEW legacy I001). +# The gate therefore DERIVES the first-party names from the consumer's own +# resolved source root and feeds them to ruff at gauge time. + + +class TestFirstPartyPackages: + def test_src_layout_names_the_package(self, tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / "src" / "bonfire").mkdir(parents=True) + (repo / "src" / "bonfire" / "__init__.py").write_text("", encoding="utf-8") + assert first_party_packages(repo) == ["bonfire"] + + def test_declared_source_root_names_its_packages(self, tmp_path: Path) -> None: + repo = _monorepo(tmp_path) + _declare(repo, '[tool.cf-quality]\nsource_root = "server/src"\n') + assert first_party_packages(repo) == ["pkg"] + + def test_top_level_modules_count(self, tmp_path: Path) -> None: + repo = _repo(tmp_path) + src = repo / "src" + src.mkdir() + (src / "single.py").write_text("x = 1\n", encoding="utf-8") + (src / "pkg").mkdir() + (src / "pkg" / "__init__.py").write_text("", encoding="utf-8") + assert first_party_packages(repo) == ["pkg", "single"] + + def test_flat_layout_excludes_non_shipping_dirs(self, tmp_path: Path) -> None: + # Repo-root source root (no src/): tests/docs/scripts are not + # first-party import names; hidden dirs never count. + repo = _repo(tmp_path) + for name in ("app", "tests", "docs", "scripts"): + (repo / name).mkdir() + (repo / name / "__init__.py").write_text("", encoding="utf-8") + (repo / ".hidden").mkdir() + (repo / ".hidden" / "__init__.py").write_text("", encoding="utf-8") + assert first_party_packages(repo) == ["app"] + + def test_namespace_package_without_init_counts(self, tmp_path: Path) -> None: + # PEP 420: a dir holding .py files is importable without __init__.py. + repo = _repo(tmp_path) + (repo / "src" / "nspkg" / "inner").mkdir(parents=True) + (repo / "src" / "nspkg" / "inner" / "mod.py").write_text("x = 1\n", encoding="utf-8") + assert first_party_packages(repo) == ["nspkg"] + + def test_python_free_repo_yields_empty(self, tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / "src").mkdir() + (repo / "src" / "app.js").write_text("const x = 1;\n", encoding="utf-8") + assert first_party_packages(repo) == [] + + def test_main_prints_toml_array( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # The CLI form is a TOML array literal so the workflow can splice it + # straight into `--config "lint.isort.known-first-party=..."`. + repo = _repo(tmp_path) + (repo / "src" / "bonfire").mkdir(parents=True) + (repo / "src" / "bonfire" / "__init__.py").write_text("", encoding="utf-8") + assert main(["first-party", "--root", str(repo)]) == 0 + assert capsys.readouterr().out.strip() == '["bonfire"]' + + def test_main_first_party_empty_array_for_python_free( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + repo = _repo(tmp_path) + assert main(["first-party", "--root", str(repo)]) == 0 + assert capsys.readouterr().out.strip() == "[]" + + def test_main_first_party_invalid_declaration_exits_typed( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + repo = _repo(tmp_path) + _declare(repo, '[tool.cf-quality]\nsource_root = "ghost"\n') + assert main(["first-party", "--root", str(repo)]) == 2 + assert "GATE_CONFIG_INVALID" in capsys.readouterr().err diff --git a/tests/test_sticky_check.py b/tests/test_sticky_check.py index 88f20eb..205a2f1 100644 --- a/tests/test_sticky_check.py +++ b/tests/test_sticky_check.py @@ -298,3 +298,134 @@ def test_main_mount_over_tampered_exit_two( assert main(["mount", str(repo)]) == 2 err = capsys.readouterr().err assert "STICKY_MOUNT_TAMPERED" in err + + +# --- the client membrane (client repos receive gate-config only, never chrome) -- +# +# The canon's client-membrane rule and the gate contradicted each other: +# client repos must NOT carry the sticky, but cf-sticky-check failed +# STICKY_CLAUDE_MD_MISSING with no waiver knob — a client repo's gate could +# never go fully green. The waiver is a COMMITTED declaration +# ([tool.cf-quality] client_repo = true), Wizard-gated like any doctrine +# surface, honored loudly (never silently), and two-way: a declared client +# repo carrying the sticky anyway FAILS. + + +def _declare_client(repo: Path, value: str = "true") -> None: + (repo / ".cf-quality.toml").write_text( + f"[tool.cf-quality]\nclient_repo = {value}\n", encoding="utf-8" + ) + + +# R1: declared client + no CLAUDE.md -> waived, loudly. + + +def test_client_repo_without_claude_md_passes(tmp_path: Path) -> None: + repo = _repo_with(tmp_path, None) + _declare_client(repo) + assert check(repo / "CLAUDE.md") == [] + + +def test_main_client_waiver_is_loud_never_silent( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repo = _repo_with(tmp_path, None) + _declare_client(repo) + assert main(["check", str(repo)]) == 0 + out = capsys.readouterr().out + assert "client membrane declared" in out + assert "ADR 0030" in out # the rule the waiver executes is named + + +# R2: no declaration -> no behavior change for the fleet. + + +def test_undeclared_repo_without_claude_md_still_fails(tmp_path: Path) -> None: + repo = _repo_with(tmp_path, None) + violations = check(repo / "CLAUDE.md") + assert [v.code for v in violations] == ["STICKY_CLAUDE_MD_MISSING"] + + +def test_client_repo_false_behaves_as_undeclared(tmp_path: Path) -> None: + repo = _repo_with(tmp_path, None) + _declare_client(repo, value="false") + violations = check(repo / "CLAUDE.md") + assert [v.code for v in violations] == ["STICKY_CLAUDE_MD_MISSING"] + + +# R3: the membrane is two-way — a declared client repo must NOT carry chrome. + + +def test_client_repo_carrying_pristine_block_fails(tmp_path: Path) -> None: + repo = _repo_with(tmp_path, "# Client repo\n\n" + canonical_text()) + _declare_client(repo) + violations = check(repo / "CLAUDE.md") + assert [v.code for v in violations] == ["STICKY_CLIENT_MEMBRANE_BREACHED"] + + +def test_client_repo_carrying_chewed_block_fails(tmp_path: Path) -> None: + repo = _repo_with(tmp_path, _tampered_block()) + _declare_client(repo) + violations = check(repo / "CLAUDE.md") + assert [v.code for v in violations] == ["STICKY_CLIENT_MEMBRANE_BREACHED"] + + +def test_client_repo_carrying_v1_era_block_fails(tmp_path: Path) -> None: + # The observed real-world shape: a client repo mounted with an OLDER + # sticky (same heading, different body) — chrome is chrome, any vintage. + v1_style = ( + "\n## The BubbleGum Law (form)\n\nThis repository" + " is governed by **the BubbleGum Law** — an older body text.\n" + ) + repo = _repo_with(tmp_path, v1_style) + _declare_client(repo) + violations = check(repo / "CLAUDE.md") + assert [v.code for v in violations] == ["STICKY_CLIENT_MEMBRANE_BREACHED"] + + +def test_client_repo_with_benign_claude_md_passes(tmp_path: Path) -> None: + # The membrane forbids the sticky/chrome, not the file: a client repo may + # carry its own instructions. + repo = _repo_with(tmp_path, "# Client repo\n\nClient-lane instructions only.\n") + _declare_client(repo) + assert check(repo / "CLAUDE.md") == [] + + +def test_client_repo_prose_mention_is_not_a_breach(tmp_path: Path) -> None: + # Naming the law in prose is not carrying its chrome. + repo = _repo_with(tmp_path, "# Client repo\n\nWe follow the BubbleGum Law upstream.\n") + _declare_client(repo) + assert check(repo / "CLAUDE.md") == [] + + +# Tampered/incomplete declaration fails loud and typed. + + +def test_client_repo_tampered_declaration_fails_typed(tmp_path: Path) -> None: + repo = _repo_with(tmp_path, None) + _declare_client(repo, value='"yes"') + with pytest.raises(GateError) as excinfo: + check(repo / "CLAUDE.md") + assert excinfo.value.code == "GATE_CONFIG_INVALID" + + +def test_main_client_tampered_declaration_exit_two( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repo = _repo_with(tmp_path, None) + _declare_client(repo, value='"yes"') + assert main(["check", str(repo)]) == 2 + assert "GATE_CONFIG_INVALID" in capsys.readouterr().err + + +# The mount refuses to push chrome through the membrane. + + +def test_mount_refuses_on_declared_client_repo(tmp_path: Path) -> None: + repo = _repo_with(tmp_path, None) + _declare_client(repo) + with pytest.raises(GateError) as excinfo: + mount(repo / "CLAUDE.md") + assert excinfo.value.code == "STICKY_MOUNT_CLIENT_MEMBRANE" + assert not (repo / "CLAUDE.md").is_file() # nothing was written diff --git a/tests/test_workflows.py b/tests/test_workflows.py index b03877e..cceb754 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -153,6 +153,7 @@ def test_gate_runs_full_battery_in_order() -> None: "cf-exemptions", "cf-import-contract", "mypy-baseline filter", + "complexipy", "pytest", ] positions = [script.find(cmd) for cmd in battery] @@ -205,6 +206,7 @@ def test_self_ci_runs_the_same_battery_on_itself() -> None: "cf-recursion-check", "cf-exemptions", "mypy", + "complexipy", "pytest", ): assert cmd in script, f"self-ci missing battery command: {cmd}"