Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m30s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m3s
#780 and #784 were green separately and red together: the gate landed on a base that predated scripts/check-doc-narrative.py, so nothing ever ran ruff over it. - RUF100 x2 on `# noqa: BLE001` — BLE is not in this repo's select, so those directives suppress nothing. Enabling BLE instead was measured and rejected: 6 further sites in decisions_validate.py, whose broad catches are deliberate. The non-enabled code is dropped; S110 and both comments stay. - scripts/tests/test_check_doc_narrative.py was not ruff-formatted. Verified with the shipped invocation: 35 files, all checks passed, all formatted; suite 807 passed / 2 skipped. refs #780, refs #784 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
261 lines
12 KiB
Python
Executable File
261 lines
12 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. The first implementation hand-parsed `git diff -U0` output in bash by
|
|
matching line prefixes, and cold review demonstrated 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.
|
|
EXEMPT_PREFIXES = ("docs/decisions/",)
|
|
|
|
# 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`.
|
|
|
|
Stated positively and in one place so the record's `mechanics:` can quote it exactly. Skills,
|
|
`web/`, and other nested markdown outside `docs/` are deliberately NOT in scope.
|
|
"""
|
|
if not path.endswith(".md"):
|
|
return False
|
|
if any(path.startswith(p) for p in EXEMPT_PREFIXES):
|
|
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.
|
|
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).
|
|
rc, listing = git("ls-files", "-z", "--", "*.md")
|
|
if rc != 0:
|
|
print("doc-narrative: could not list tracked files — SCANNED NOTHING.")
|
|
return -1
|
|
scanned = 0
|
|
for path in listing.split("\0"):
|
|
if not path 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)
|