diff --git a/docs/changelog-20260727-stage5-phase0.md b/docs/changelog-20260727-stage5-phase0.md index f93e998..a77ad47 100644 --- a/docs/changelog-20260727-stage5-phase0.md +++ b/docs/changelog-20260727-stage5-phase0.md @@ -162,3 +162,43 @@ gauntlet cannot be green there until P0-1 is resolved, so any GA-R6 close citing a gauntlet figure measured there inherits a known red -- the citation must name its host. + +--- + +## 6. R15(1) -- repo-lint can no longer PASS over zero files + +**What.** `scripts/repo_lint.py`: reject unrecognised options instead of treating them +as the repo root; reject >1 positional root; require the root to be a directory AND to +carry repo markers (`docs/`, `scripts/`); add a files-scanned floor; and PRINT the +files-scanned count in the summary line. + +**Why.** R15(1), ruled 2026-07-27. Reproduced before fixing, both cases: +`python3 scripts/repo_lint.py --recordd` and `... /nonexistent-path` each printed +`PASS: repo lint (0 fail, 0 warn)` with **exit 0**. Only the two known flags were +stripped, so any other `--flag` became `argv[0]` i.e. the ROOT, which resolved nowhere, +`rglob` yielded nothing, and zero findings read as clean. **This is the gate whose +"0-fail" every GA-R6 stage close in this project's history cites.** The printed count +also closes a gap found during Phase 0: repo-lint emitted no files-scanned figure at +all, so a reader could not distinguish a thorough pass from a vacuous one. + +**Design note -- the floor SCALES, and a flat one was wrong.** First implementation used +a flat floor of 100 and **failed 20 of the 47 existing harness cases**, because +`tests/repo-lint` legitimately builds ~7-file fixture repos to plant one violation each. +The strong floor now applies only to a full checkout of THIS repo (detected via +`scripts/preflight.sh` + `docs/security-ledger.md`, files no fixture creates); any other +tree must still scan at least one file, which is what catches "the walk filters emptied +the scan". Caught by running the harness, not by review. + +**Verification.** Harness `tests/repo-lint` **54/54** (was 47) -- seven new cases +T48-T54, each locking a defect reproduced before the fix, including T53 (full-repo +markers + near-empty tree trips the floor) and T54 (the floor does NOT punish a +legitimate minimal tree). Gauntlet ALL GREEN (82). Real repo: 0 fail, 1 standing warn, +**596 files scanned**. + +**No consumer breaks:** `preflight.sh` P1 consumes the EXIT CODE (`note $?`), not the +summary text; the harness regexes match on `PASS: repo lint` which is unchanged as a +prefix. Both verified. + +**Revert.** `git revert `. The lint reverts to accepting any unknown flag +as its root -- i.e. back to a gate that cannot distinguish a clean repo from an +unexamined one. diff --git a/scripts/repo_lint.py b/scripts/repo_lint.py index bc33b22..89630b2 100644 --- a/scripts/repo_lint.py +++ b/scripts/repo_lint.py @@ -127,12 +127,67 @@ # (DOCFIX-135/136 skill-lag). This makes "sweep the skill" a hard gate, not a note. """ +# ---- R15(1): the gate must not be able to pass over nothing -------------------- +# Ruled 2026-07-27 ("All three, with a harness MANIFEST rather than a count"), OPS. +# MEASURED DEFECT: this function stripped only the two KNOWN flags, so ANY other +# --flag fell through to argv[0] and became the REPO ROOT. With no is_dir() check +# and no floor, rglob then yielded nothing and the script printed +# PASS: repo lint (0 fail, 0 warn) +# with exit 0. Reproduced on `--recordd` and on a nonexistent path. This is the +# gate whose "0-fail" every GA-R6 stage close in this project's history cites, and +# it could not distinguish a clean repo from an unexamined one. +MIN_FILES_SCANNED = 100 # floor, not a pin. ~596 in scope at the 2026-07-27 fix, + # so a ~6x margin: catches zero/catastrophic-mis-root + # without going brittle as the repo grows or shrinks. + # Exact-set pinning is the harness MANIFEST's job (R15(2)), + # deliberately not this floor's. +# The floor SCALES to the tree, because a flat floor is wrong: this lint is also run +# against deliberately minimal fixture trees (tests/repo-lint builds ~7-file repos to +# plant one violation each), and those are legitimately small. A flat 100 failed 20 of +# them on first run. So the STRONG floor applies only to a full checkout of THIS repo, +# detected by files no fixture creates; any other tree still must scan at least one +# file, which is what catches "the walk filters emptied the scan". +FULL_REPO_MARKERS = ("scripts/preflight.sh", "docs/security-ledger.md") + + def main(): argv = [a for a in sys.argv[1:]] record = RECORD_FLAG in argv coupling_record = COUPLING_FLAG in argv argv = [a for a in argv if a not in (RECORD_FLAG, COUPLING_FLAG)] + + # R15(1)a -- reject UNRECOGNISED options instead of treating them as the root. + # This is the actual root cause of the zero-file false green: a one-character + # typo of a flag silently became the directory to scan. + unknown = [a for a in argv if a.startswith("-")] + if unknown: + print("FAIL: repo lint -- unrecognised option(s): %s" % " ".join(unknown)) + print(" known options: %s | %s" % (RECORD_FLAG, COUPLING_FLAG)) + print(" (an unrecognised flag used to be taken as the REPO ROOT, which " + "resolved to a nonexistent directory and reported PASS over ZERO files)") + return 1 + if len(argv) > 1: + print("FAIL: repo lint -- expected at most one repo-root path, got %d: %s" + % (len(argv), " ".join(argv))) + return 1 + R = pathlib.Path(argv[0] if argv else ".").resolve() + + # R15(1)b -- the root must exist, be a directory, and actually look like THIS + # repo. "Could not look" must never read as "nothing there". + if not R.is_dir(): + print("FAIL: repo lint -- repo root is not a directory: %s" % R) + print(" nothing was scanned; this is NOT a clean result") + return 1 + missing_markers = [m for m in ("docs", "scripts") if not (R / m).is_dir()] + if missing_markers: + print("FAIL: repo lint -- %s does not look like this repo (missing: %s)" + % (R, ", ".join(missing_markers + []))) + print(" refusing to report a verdict over a tree this lint does not " + "understand -- an unrecognised state REFUSES rather than defaulting " + "to success") + return 1 + fails, warns = [], [] # ---- L7 helpers (clientdocs sweep-on-change, DOCFIX-104) ---- @@ -499,12 +554,29 @@ "must NEVER run against repo-tracked prose (CLAUDE.md; SEC-017)." % rel) + # R15(1)c -- files-scanned FLOOR. Even with a valid root, a future refactor of + # the walk filters could silently empty the scan; a verdict over ~nothing must + # not read as clean. The count is also PRINTED: before this fix the script + # emitted no files-scanned figure at all, so a reader had no way to tell a + # thorough pass from a vacuous one (found again during the Stage-5 Phase 0 runs). + scanned = len(all_text()) + full_repo = all((R / m).is_file() for m in FULL_REPO_MARKERS) + floor = MIN_FILES_SCANNED if full_repo else 1 + if scanned < floor: + fails.append("L0 files-scanned floor: only %d file(s) in scope under %s " + "(floor %d, %s). A lint verdict over ~nothing is not a clean " + "repo -- investigate the root or the walk filters before " + "trusting any result above." + % (scanned, R, floor, + "full repo checkout" if full_repo else "minimal tree")) + for w in warns: print(" [WARN] %s" % w) for f in fails: print(" [FAIL] %s" % f) verdict = "FAIL" if fails else ("WARN" if warns else "PASS") - print("\n%s: repo lint (%d fail, %d warn)" % (verdict, len(fails), len(warns))) + print("\n%s: repo lint (%d fail, %d warn, %d files scanned)" + % (verdict, len(fails), len(warns), scanned)) return 1 if fails else (2 if warns else 0) if __name__ == "__main__": diff --git a/tests/repo-lint/run-tests.sh b/tests/repo-lint/run-tests.sh index 3950a6c..b8f1034 100644 --- a/tests/repo-lint/run-tests.sh +++ b/tests/repo-lint/run-tests.sh @@ -221,5 +221,49 @@ D=$(mkl12 l12b); printf 'moved\n' >> "$D/docs/CURRENT-STATE.md" run 0 'PASS: repo lint' "T46 L12 does NOT fire when CURRENT-STATE moves with it" "$D" +# ---- T48-T54 -- R15(1): THE GATE MUST NOT BE ABLE TO PASS OVER NOTHING ---- +# Ruled 2026-07-27. MEASURED defect: only the two known flags were stripped, so any +# OTHER --flag fell through to argv[0] and became the REPO ROOT; with no is_dir() +# check and no floor, rglob yielded nothing and this printed +# PASS: repo lint (0 fail, 0 warn) exit 0 +# over ZERO files. This is the gate whose "0-fail" every GA-R6 stage close cites. +# These cases are the regression lock: each was reproduced BEFORE the fix. +runargv() { # runargv