diff --git a/.claude/skills/ci-gates/SKILL.md b/.claude/skills/ci-gates/SKILL.md index 18178d7d38..5b66199966 100644 --- a/.claude/skills/ci-gates/SKILL.md +++ b/.claude/skills/ci-gates/SKILL.md @@ -7993,3 +7993,34 @@ assert_eq!(k.matches("parse error").count(), 1, "{k}"); And its control — two DIFFERENT causes must not collapse into one bucket — matters more than the merge test. Over-merging turns 39 kinds into 4 and reads as excellent grouping. + +## 316. A verification script that has never parsed + +`scripts/verify_all_152.py` — the name says instrument — carries eight +unresolved `Updated upstream` / `Stashed changes` conflicts, two of them +nested. `ast.parse` on it is a SyntaxError. It has been that way since the +commit that INTRODUCED it, so there is no clean revision to restore, and +nothing in the repository imports or runs it. + +Nothing looked for the shape. A second marker sat in this very skill file for +weeks and was found by hand while resolving an unrelated merge. `tri skill +check` read that file and reported OK, because it checks section numbering. + +Three things worth keeping: + +1. **A file's NAME is a claim.** Anyone scanning the tree sees + `verify_all_152.py` and concludes the 152 formulas are verified. Grep for + what runs an instrument before believing the instrument exists. +2. **Design the abstention first.** The gate refuses a labelled `<<<<<<< x` or + `>>>>>>> x` and says nothing about a bare seven-equals line — that is an + ordinary Markdown rule and this repository has hundreds. Git always writes + the divider BETWEEN two labelled markers, so the pair alone is sufficient + and invents no false positives. +3. **A gate that names marker shapes must not contain one.** Built from + `"<" * 7` rather than a literal, or the gate refuses its own source. The + first draft did exactly that. + +And the fix that was NOT made: resolving the file means choosing which of 152 +numeric formulas is right. A gate may record that debt with its reason; it may +not invent the content. The baseline line says why, and the gate reports when +a baseline entry outlives its debt. diff --git a/.github/workflows/conflict-markers.yml b/.github/workflows/conflict-markers.yml new file mode 100644 index 0000000000..60a21d439d --- /dev/null +++ b/.github/workflows/conflict-markers.yml @@ -0,0 +1,45 @@ +# A tracked file must not carry a merge conflict marker. +# +# `scripts/verify_all_152.py` -- a verification script by its name -- has held +# eight unresolved `Updated upstream` / `Stashed changes` conflicts since the +# commit that introduced it. It has never parsed. A second marker sat in +# `.claude/skills/ci-gates/SKILL.md` for weeks and was found by hand while +# resolving an unrelated merge, not by any check. +# +# No `paths:` filter, and the reason is specific to this gate rather than +# inherited: a marker can land in ANY file, so a filter here would be a filter +# on where the defect is allowed to hide. It is also the conflicting PR -- +# whose merge diff may not compute -- that most needs the reading. +# +# The self-check runs first and its exit code is read. A gate whose own +# failure path is untested is an assertion, not a measurement. +name: Conflict Markers + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: conflict-markers-${{ github.ref }} + cancel-in-progress: true + +jobs: + markers: + runs-on: ubuntu-latest + name: Conflict markers (no unresolved merges) + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: The gate can fail + run: python3 tools/check_conflict_markers.py --self-check + + - name: No tracked file carries a marker + run: python3 tools/check_conflict_markers.py diff --git a/docs/now/2026-08-30-a-script-named-verify-all-152-py-has-never-parsed.md b/docs/now/2026-08-30-a-script-named-verify-all-152-py-has-never-parsed.md new file mode 100644 index 0000000000..ee770c2bc1 --- /dev/null +++ b/docs/now/2026-08-30-a-script-named-verify-all-152-py-has-never-parsed.md @@ -0,0 +1,8 @@ +# NOW -- A script named verify_all_152.py has never parsed (2026-08-30) + +## A script named verify_all_152.py has never parsed (Refs #2873) + +- scripts/verify_all_152.py carries eight unresolved conflict markers, two nested, present since the commit that introduced it -- checked: no earlier clean revision exists. ast.parse is a SyntaxError. Nothing imports or runs it. +- New gate tools/check_conflict_markers.py: 7592 tracked files read, 1 carrying markers, 60 not read and said so. Abstains on a bare seven-equals divider, which is an ordinary Markdown rule here. +- Recorded in tools/conflict_markers_baseline.txt with the reason rather than repaired: resolving it means choosing which of 152 numeric formulas is right, which is not a gate's judgement. +- Workflow has no paths filter on purpose -- a marker can land in any file, and the conflicting PR most needs the reading. diff --git a/tools/check_conflict_markers.py b/tools/check_conflict_markers.py new file mode 100644 index 0000000000..bc1b58afbc --- /dev/null +++ b/tools/check_conflict_markers.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Refuse a tracked file that still carries a merge conflict marker. + +WHY THIS EXISTS +--------------- +`scripts/verify_all_152.py` -- a verification script, by its name -- has +carried eight unresolved `Updated upstream` / `Stashed changes` conflicts +since the commit that introduced it (f1fb1456b). It has never parsed. Nothing +imports it, nothing runs it, and no check in this repository looks for the +shape. A second one sat in `.claude/skills/ci-gates/SKILL.md` for weeks and +was found by hand while resolving an unrelated merge. + +A conflict marker is not a style question. In Python it is a SyntaxError; in +Markdown it is two contradictory paragraphs presented as one; in YAML it is a +workflow that will not load. The file is broken in every case, and the shape +is unambiguous enough to be checked in one pass. + +WHAT IT ABSTAINS ON +------------------- +A bare `=======` line. Seven equals signs with nothing after them is a common +Markdown rule and a common ASCII divider, and this repository has hundreds. +Git always writes it BETWEEN an opening and a closing marker, both of which +carry a label, so refusing on those two alone loses nothing and invents no +false positives. +""" +import subprocess +import sys +from pathlib import Path + +# Built from pieces on purpose: a literal marker in this file would make the +# gate refuse its own source, which is the trap the first draft fell into. +OPEN = "<" * 7 + " " +CLOSE = ">" * 7 + " " + +BASELINE = Path("tools/conflict_markers_baseline.txt") + +SKIP_SUFFIX = {".lock", ".png", ".jpg", ".gif", ".pdf", ".bin", ".bit", ".fasm"} + + +def tracked_files(root: Path): + out = subprocess.run( + ["git", "ls-files", "-z"], cwd=root, capture_output=True, text=True + ) + for name in out.stdout.split("\0"): + if not name: + continue + p = root / name + if p.suffix in SKIP_SUFFIX or not p.is_file(): + continue + yield name, p + + +def markers_in(path: Path): + """Line numbers carrying an opening or closing marker, or None if unread.""" + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + return None # binary or unreadable: not this gate's question + hits = [] + for n, line in enumerate(text.splitlines(), 1): + if line.startswith(OPEN) or line.startswith(CLOSE): + hits.append(n) + return hits + + +def load_baseline(root: Path): + f = root / BASELINE + if not f.is_file(): + return set() + known = set() + for line in f.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#"): + known.add(line.split("|")[0].strip()) + return known + + +def scan(root: Path): + known = load_baseline(root) + found, unread, stale = {}, 0, [] + for name, p in tracked_files(root): + hits = markers_in(p) + if hits is None: + unread += 1 + continue + if hits: + found[name] = hits + for name in sorted(known): + if name not in found: + stale.append(name) + return found, known, unread, stale + + +def main(argv): + root = Path(__file__).resolve().parent.parent + if "--self-check" in argv: + return self_check(root) + + found, known, unread, stale = scan(root) + new = {k: v for k, v in found.items() if k not in known} + + print(f" tracked files read {sum(1 for _ in tracked_files(root)) - unread}") + print(f" carrying a conflict marker {len(found)}") + if known: + print(f" ... of those, known debt {len(found) - len(new)}") + if unread: + print(f" NOT READ, nothing claimed {unread}") + + for name in sorted(stale): + print() + print(f" {name} is in the baseline and is CLEAN now.") + print(" Remove its line: a baseline that outlives its debt starts") + print(" excusing a defect nobody has re-introduced yet.") + + if not new: + print() + if found: + print(" Every marker found is recorded as debt. Nothing new.") + else: + print(" No tracked file carries a conflict marker.") + return 1 if stale else 0 + + for name, lines in sorted(new.items()): + shown = ", ".join(str(n) for n in lines[:6]) + more = "" if len(lines) <= 6 else f" (+{len(lines) - 6} more)" + print() + print(f" {name}") + print(f" conflict marker on line {shown}{more}") + print() + print(" A conflict marker is a broken file, not a formatting question.") + print(" Resolve it, or -- if the content cannot be judged -- record it in") + print(f" {BASELINE} with the reason, so the debt is named rather than green.") + return 1 + + +def self_check(root: Path): + """Plant a marker, demand it is seen; remove it, demand it is not.""" + import tempfile + + ok = True + with tempfile.TemporaryDirectory() as d: + t = Path(d) + subprocess.run(["git", "init", "-q"], cwd=t, check=True) + (t / "a.py").write_text("x = 1\n") + (t / "b.md").write_text("Title\n" + "=" * 7 + "\n\nbody\n") + subprocess.run(["git", "add", "-A"], cwd=t, check=True) + + found, _, _, _ = scan(t) + clean = not found + print(f" clean tree {'no marker seen' if clean else 'FALSE POSITIVE'}") + ok &= clean + + # A bare ======= divider must NOT fire: it is the abstention. + bare = "b.md" not in found + print(f" bare {'=' * 7} divider {'abstained' if bare else 'FALSE POSITIVE'}") + ok &= bare + + (t / "a.py").write_text(f"x = 1\n{OPEN}HEAD\ny = 2\n" + "=" * 7 + f"\ny = 3\n{CLOSE}other\n") + subprocess.run(["git", "add", "-A"], cwd=t, check=True) + found, _, _, _ = scan(t) + # The closing marker is line 6, not 5: the planted text has a + # divider between the two halves. Counted wrong on the first run, + # and the self-check is what said so. + seen = found.get("a.py") == [2, 6] + print(f" planted marker {'seen at 2 and 6' if seen else f'MISSED: {found}'}") + ok &= seen + + # A baselined file must fall out of the NEW set but stay counted. + (t / "tools").mkdir() + (t / BASELINE).write_text("# debt\na.py | never parsed\n") + subprocess.run(["git", "add", "-A"], cwd=t, check=True) + found, known, _, stale = scan(t) + excused = "a.py" in found and "a.py" in known and not stale + print(f" baselined {'counted, not new' if excused else 'BASELINE BROKEN'}") + ok &= excused + + # And a baseline that outlived its debt must be reported. + (t / "a.py").write_text("x = 1\n") + subprocess.run(["git", "add", "-A"], cwd=t, check=True) + _, _, _, stale = scan(t) + caught = stale == ["a.py"] + print(f" baseline outlived its debt {'reported' if caught else f'MISSED: {stale}'}") + ok &= caught + + # This file names the marker shapes without containing one. + src = Path(__file__).read_text() + selfclean = not any(l.startswith(OPEN) or l.startswith(CLOSE) for l in src.splitlines()) + print(f" the gate's own source {'clean' if selfclean else 'TRIPS ITSELF'}") + ok &= selfclean + + print() + print(" self-check PASSED" if ok else " self-check FAILED") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/conflict_markers_baseline.txt b/tools/conflict_markers_baseline.txt new file mode 100644 index 0000000000..e08526c26e --- /dev/null +++ b/tools/conflict_markers_baseline.txt @@ -0,0 +1,8 @@ +# Tracked files that still carry a merge conflict marker. +# +# Each line is a broken file, not an accepted style. Remove the line when the +# file is resolved; the gate then holds it resolved, and says so if the line +# outlives the debt. +# +# Format: | why it is here rather than fixed +scripts/verify_all_152.py | eight conflicts, some nested, present since the commit that introduced the file (f1fb1456b) -- it has never parsed, so there is no clean revision to restore. Nothing imports or runs it. Choosing a side would be inventing which of 152 numeric formulas is right; that is the owner's call, not a gate's.