Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
315 lines
16 KiB
Python
Executable File
315 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""ersatztv#784 — ADVISORY nudge for `docs.no-session-narrative`.
|
|
|
|
A doc records the END STATE; the path to it belongs in the commit message, not the artifact.
|
|
|
|
THIS NEVER FAILS. Every path returns exit 0 — including a bad argument, an unresolvable base ref,
|
|
an unreadable file and an unhandled exception. That is a design constraint, not an oversight: a
|
|
narrative detector is a string predicate over prose, and `docs/defect-shapes-773.md` §4 plus
|
|
`testing.guard-derives-population-from-source` both argue that class must not be load-bearing (the
|
|
withdrawn `test_review_verdict_vocabulary_parity.py` — six review rounds, then deleted — is the
|
|
empirical case). Do not convert this into a gate; the decision record says no in as many words.
|
|
|
|
WHY PYTHON AND NOT SHELL. Hand-parsing `git diff -U0` output in bash by matching line prefixes
|
|
produced four separate defects in that one parser: the
|
|
`\\ No newline at end of file` marker was counted as content, an added line whose own text began
|
|
`++ ` was eaten by the `+++ ` header arm, `core.quotePath` hid non-ASCII paths, and `read` dropped a
|
|
final unterminated line. Those are four instances of one mistake — deciding what a diff line IS from
|
|
its prefix alone, with no hunk state. Patching them one at a time is the shape this repo has
|
|
recorded as never converging, so the mechanism was replaced rather than the sites.
|
|
|
|
check-doc-narrative.py --diff <base-ref> scan lines ADDED against <base-ref> (the CI mode)
|
|
check-doc-narrative.py --all scan the whole tracked corpus (deliberate sweep)
|
|
|
|
Scanning only ADDED lines in CI is what keeps the existing corpus of legitimate history out of the
|
|
output; `--all` deliberately reports all of it, for a human to apply the who-benefits test to.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
# The never-fails invariant must not depend on the ambient locale. Both the summary line and the
|
|
# last-resort handler below carry non-ASCII text, so under ascii/latin-1 stdio the very code meant to
|
|
# guarantee exit 0 is what raises. Degrade unencodable characters instead of failing on them.
|
|
try:
|
|
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
|
sys.stderr.reconfigure(errors="replace") # type: ignore[union-attr]
|
|
except Exception: # noqa: S110 — a stdout that cannot be reconfigured is not a reason to fail
|
|
pass # deliberate: this is the never-fails invariant's own setup, so it cannot itself raise
|
|
|
|
# `docs/decisions/**` is exempt WHOLESALE — a decision record narrating how a rule was got wrong is
|
|
# carrying the rationale it exists to carry, so a detector that flagged it would fight the
|
|
# convention it serves.
|
|
#
|
|
# `docs/superpowers/**` was CONSIDERED for this list in #812 and deliberately REJECTED. Recorded here
|
|
# so it is not re-proposed on plausibility: the argument was that those 35 dated plans are frozen
|
|
# (measured 2026-08-29, before this change: nothing edited there since 2026-07-23), so a sweep over
|
|
# them yields only carve-out false positives. But
|
|
# `--diff` — the mode CI runs — scans ADDED lines, and a frozen file contributes none, so exempting
|
|
# them buys nothing there. What it WOULD suppress is any plan being WRITTEN OR REVISED now — 15 of
|
|
# the 35 have more than one commit, so revisions do happen — which is precisely the case where the
|
|
# rule's remedy (move it to the commit message) is still available. The exemption costs the only
|
|
# reach the detector has and buys only quiet in a sweep a person runs deliberately.
|
|
#
|
|
# `scripts/tests/fixtures/` is exempt for the same reason one level down (#876): it holds COPIES of
|
|
# decision records and other test DATA, which carry whatever phrasing the test under them needs and
|
|
# are not an artifact anyone edits for a reader. Exempting it is also what keeps the depth
|
|
# measurement in the record true — its record copies sit at four to six slashes.
|
|
EXEMPT_PREFIXES = ("docs/decisions/", "scripts/tests/fixtures/")
|
|
|
|
# The PROCESS corpus (#876): hooks, workflows, scripts and their tests, scanned regardless of
|
|
# extension because the artifacts there are shell, YAML, Python and jq, not Markdown. It is in the
|
|
# population on the #812 argument run forward — `--diff` sees only ADDED lines, and this is where
|
|
# narrative is being ADDED: 287 of the 453 sites the #876 sweep found outside the docs corpus were
|
|
# under 30 days old (measured 2026-09-03 at `fb5592971`), against a `docs/superpowers/**` that had
|
|
# not moved since 2026-07-23. `web/` and the C# projects are left out on the same measurement: 3 of
|
|
# the 74 PATTERNS-matching sites lived there, across roughly 4,600 tracked files.
|
|
PROCESS_PREFIXES = (".claude/", ".gitea/", ".husky/", "scripts/")
|
|
|
|
# Exempt by NAME, not by prefix: this file and its test carry the phrasings as pattern and fixture,
|
|
# so they would be permanent hits — the false positive that makes an advisory check stop being read.
|
|
EXEMPT_FILES = ("scripts/check-doc-narrative.py", "scripts/tests/test_check_doc_narrative.py")
|
|
|
|
# Session-narrative phrasings. Deliberately narrow: each is first person or names a revision of THIS
|
|
# artifact. Broad words that also appear in legitimate dated history ("previously", "was wrong") are
|
|
# absent on purpose — a false positive on a carved-out case is what makes an advisory check stop
|
|
# being read.
|
|
PATTERNS = re.compile(
|
|
r"an earlier draft"
|
|
r"|earlier drafts"
|
|
r"|the (?:first|previous|original) (?:version|draft) of (?:this|the)"
|
|
r"|my first attempt"
|
|
r"|I (?:initially|first|originally|then) (?:thought|assumed|tried|wrote|found)"
|
|
r"|we (?:then|initially) (?:found|thought|realis|realiz)"
|
|
r"|it turned out that"
|
|
r"|earlier today"
|
|
r"|as of just now"
|
|
r"|currently investigating",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
WARNING = (
|
|
"{path}:{line} reads as session narrative — a reader coming cold never saw the earlier draft. "
|
|
"Answer the review finding in the COMMIT MESSAGE and let only the corrected claim enter the "
|
|
"doc (docs.no-session-narrative). Keep it only if a reader would ACT differently knowing it "
|
|
"(dated measurement, stated snapshot boundary, tested-and-rejected result, a trap and its "
|
|
"consequence). Line: {text}"
|
|
)
|
|
|
|
|
|
def is_scanned_path(path: str) -> bool:
|
|
"""The population: `docs/**/*.md` minus `docs/decisions/**`, plus root-level `*.md`, plus every
|
|
tracked file under `PROCESS_PREFIXES` regardless of extension, minus `EXEMPT_FILES`.
|
|
|
|
Stated positively and in one place so the record's `mechanics:` can quote it exactly. `web/`,
|
|
C# source, and nested markdown outside `docs/` and the process prefixes are deliberately NOT in
|
|
scope. This is a PATH predicate; `run_all` additionally restricts the population to tracked
|
|
regular files, because a symlink's content is its target, not the artifact.
|
|
"""
|
|
if path in EXEMPT_FILES or any(path.startswith(p) for p in EXEMPT_PREFIXES):
|
|
return False
|
|
if any(path.startswith(p) for p in PROCESS_PREFIXES):
|
|
return True
|
|
if not path.endswith(".md"):
|
|
return False
|
|
return path.startswith("docs/") or "/" not in path
|
|
|
|
|
|
# Git's OUTPUT FORMAT is configurable, and this script reads paths and line numbers out of that
|
|
# format. Three separate knobs were each demonstrated turning a real hit into `scanned 0 file(s)` —
|
|
# `core.quotePath` hiding non-ASCII paths, `diff.dstPrefix` rewriting the header, `color.diff=always`
|
|
# injecting ANSI escapes. Pinning them one at a time is refuting variants, not clearing the channel,
|
|
# so the channel is closed at both ends: the user's and the system's config files are taken out of
|
|
# the picture entirely (which also covers knobs nobody has thought of yet), and the handful that a
|
|
# REPO-local config could still set are pinned explicitly on the command line, where they win.
|
|
GIT_ENV_OVERRIDES = {
|
|
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
"GIT_CONFIG_SYSTEM": os.devnull,
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
}
|
|
# `core.quotePath=false` is witnessed by a test. The env overrides above are NOT, and cannot be: they
|
|
# exist for the knob nobody has named yet, which is exactly what three rounds of naming one knob at a
|
|
# time argued for. Every knob that IS named is pinned on the command line, where it also beats a
|
|
# repo-local config, and has a row in FORMAT_KNOBS in the test file.
|
|
GIT_CONFIG_PINS = ("-c", "core.quotePath=false")
|
|
|
|
|
|
def git(*args: str) -> tuple[int, str]:
|
|
"""Run git with its output format pinned. Returns (returncode, stdout) and never raises."""
|
|
try:
|
|
p = subprocess.run(
|
|
["git", *GIT_CONFIG_PINS, *args],
|
|
capture_output=True,
|
|
text=True,
|
|
errors="replace",
|
|
env={**os.environ, **GIT_ENV_OVERRIDES},
|
|
)
|
|
except OSError as exc: # git missing, or not a repo we can exec in
|
|
return 1, f"{exc}"
|
|
if p.returncode != 0:
|
|
sys.stderr.write(p.stderr)
|
|
return p.returncode, p.stdout
|
|
|
|
|
|
def scan_line(path: str, lineno: int, text: str, out: list[str]) -> None:
|
|
if PATTERNS.search(text):
|
|
detail = WARNING.format(path=path, line=lineno, text=text[:160])
|
|
out.append(f"::warning file={path}::{detail}")
|
|
|
|
|
|
def added_lines(diff: str):
|
|
"""Yield (path, lineno, text) for every ADDED line in a unified diff.
|
|
|
|
A line's meaning comes from HUNK STATE, not from its prefix: `+++ ` is a header only before the
|
|
first `@@` of a file, and inside a hunk it is content whose own text starts `++ `. That
|
|
distinction is the whole reason this is not a prefix match.
|
|
"""
|
|
path = None
|
|
in_hunk = False
|
|
lineno = 0
|
|
for raw in diff.split("\n"):
|
|
if raw.startswith("diff --git "):
|
|
path, in_hunk = None, False
|
|
elif raw.startswith("@@"):
|
|
m = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", raw)
|
|
if not m:
|
|
in_hunk = False
|
|
continue
|
|
in_hunk = True
|
|
lineno = int(m.group(1)) - 1
|
|
elif not in_hunk:
|
|
if raw.startswith("+++ "):
|
|
p = raw[4:]
|
|
# `/dev/null` on the new side means the file was DELETED. Stated honestly: this arm
|
|
# is DEFENSIVE, not load-bearing — a deletion contributes no `+` lines, so nothing is
|
|
# yielded for it either way, and removing this arm reddens no test. It is kept because
|
|
# `path` should never name a file the added lines do not belong to. A
|
|
# `--diff-filter=d` on the git call was removed rather than kept beside it: a second
|
|
# mechanism nobody can witness failing is how a duplicate guard hides its twin.
|
|
# Git terminates the `+++` filename with a TAB when the path contains a space, and a
|
|
# path carrying that tab matches nothing in `is_scanned_path` — the file is dropped
|
|
# SILENTLY, the same scanned-0 channel the config pins above close. Witnessed by a
|
|
# positive control with a space in the name.
|
|
p = p.split("\t", 1)[0]
|
|
path = None if p == "/dev/null" else (p[2:] if p.startswith("b/") else p)
|
|
elif raw.startswith("+"):
|
|
lineno += 1
|
|
if path is not None:
|
|
yield path, lineno, raw[1:]
|
|
elif raw.startswith("-") or raw.startswith("\\"):
|
|
pass # a removed line, or the no-trailing-newline marker: neither advances the new file
|
|
else:
|
|
lineno += 1 # context (absent at -U0, but harmless and correct if -U grows)
|
|
|
|
|
|
def run_diff(base: str, out: list[str]) -> int:
|
|
rc, diff = git(
|
|
"diff",
|
|
"-U0",
|
|
# Pinned, not decorative: `diff.renames=false` in a developer's gitconfig turns a `git mv`
|
|
# into a whole-file add and re-flags every pre-existing line. Same channel as the prefixes.
|
|
"--find-renames",
|
|
# Pin the header shape the path is parsed out of. `diff.noprefix`, `diff.srcPrefix` and
|
|
# `diff.dstPrefix` each rewrite it from a developer's gitconfig, and `diff.external` replaces
|
|
# the output entirely — a configured prefix silently produced a scanned-0-files clean run.
|
|
"--src-prefix=a/",
|
|
"--dst-prefix=b/",
|
|
"--no-ext-diff",
|
|
"--no-color",
|
|
f"{base}...HEAD",
|
|
)
|
|
if rc != 0:
|
|
print(
|
|
f"doc-narrative: could not diff against '{base}' — SCANNED NOTHING. "
|
|
"This is reported rather than swallowed: a silent zero-file scan is indistinguishable "
|
|
"from a clean one, which is the failure `ci.required-job-step-execution-markers` exists for."
|
|
)
|
|
return -1
|
|
scanned = set()
|
|
for path, lineno, text in added_lines(diff):
|
|
if not is_scanned_path(path):
|
|
continue
|
|
scanned.add(path)
|
|
scan_line(path, lineno, text, out)
|
|
return len(scanned)
|
|
|
|
|
|
def run_all(out: list[str]) -> int:
|
|
# Population from `git ls-files`, never a filesystem walk — an untracked scratch file is not
|
|
# part of the corpus (#778). The STAGE listing, because the population is tracked REGULAR files
|
|
# (mode 100644/100755): a tracked symlink (120000) is a path the predicate admits whose worktree
|
|
# content is its TARGET — a directory (`.claude/skills/jellyfin`, a cross-repo skill link) or a
|
|
# file that may sit outside the population — and a gitlink (160000) is a submodule pointer with
|
|
# no content of its own. `--diff` carries no such filter: a gitlink's diff content is a sha, and
|
|
# a symlink's is its target PATH, scanned like any other added line — a phrasing inside a path
|
|
# name would draw an advisory warning on the link, which is harmless. The stage listing emits
|
|
# one row PER STAGE for an unmerged path, so modes are gathered per path first: a path is opened
|
|
# once, and only when EVERY stage is a regular blob — in an add/add conflict between a symlink
|
|
# and a regular file, the worktree holds one of them and a per-row test would let the regular
|
|
# row authorise opening the other.
|
|
rc, listing = git("ls-files", "-s", "-z")
|
|
if rc != 0:
|
|
print("doc-narrative: could not list tracked files — SCANNED NOTHING.")
|
|
return -1
|
|
modes: dict[str, set[str]] = {}
|
|
for entry in listing.split("\0"):
|
|
if not entry:
|
|
continue
|
|
meta, _, path = entry.partition("\t")
|
|
modes.setdefault(path, set()).add(meta.split(" ", 1)[0])
|
|
scanned = 0
|
|
for path, path_modes in modes.items():
|
|
if not all(m.startswith("100") for m in path_modes) or not is_scanned_path(path):
|
|
continue
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
text = fh.read()
|
|
except OSError as exc:
|
|
# A tracked-but-deleted doc is an ordinary working state, not a reason to fail.
|
|
print(f"doc-narrative: skipped {path} ({exc.strerror}).")
|
|
continue
|
|
scanned += 1
|
|
# splitlines() keeps a final unterminated line, which `read`-per-line dropped.
|
|
for i, line in enumerate(text.splitlines(), start=1):
|
|
scan_line(path, i, line, out)
|
|
return scanned
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
mode = argv[1] if len(argv) > 1 else "--all"
|
|
out: list[str] = []
|
|
|
|
if mode == "--diff":
|
|
if len(argv) < 3 or not argv[2]:
|
|
print("doc-narrative: --diff needs a base ref — SCANNED NOTHING. (advisory; not a failure)")
|
|
return 0
|
|
scanned = run_diff(argv[2], out)
|
|
elif mode == "--all":
|
|
scanned = run_all(out)
|
|
else:
|
|
print(f"doc-narrative: unknown mode '{mode}'. usage: {argv[0]} [--all | --diff <base-ref>]")
|
|
return 0
|
|
|
|
for line in out:
|
|
print(line)
|
|
if scanned < 0:
|
|
return 0
|
|
print(
|
|
f"doc-narrative: scanned {scanned} file(s); {len(out)} advisory warning(s). "
|
|
"NON-BLOCKING — this check never fails a run."
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main(sys.argv))
|
|
# The never-fails constraint outranks a clean traceback, so this catch is deliberately blind.
|
|
except Exception as exc:
|
|
print(f"doc-narrative: internal error ({exc!r}) — SCANNED NOTHING. Advisory; not a failure.")
|
|
sys.exit(0)
|