Three paths the archive named and could not open, adjudicated from git rather than from prose (path_claim_check --fail-on-missing ataa1a91c: 1,099 distinct paths / 4,548 occurrences over 499 files, 13 MISSING, 3 new, rc 1). 1. replies/2026-10-21-to-beam-10.md -- RESTORED, blob-identical.ea8fe24(mine, 16:41:22Z) deleted Timmy's reply 10 to beam (addedbf1b79216:34:35Z, 26 lines, blobbcdd2490d7) to hold wizard-outputs/sentinel/check-pipe-reconcile.sh's hardcoded [ "$n_rep" = 249 ]. replies/ held 256 files before this tick, so the deletion restored no invariant; git checkoutbf1b792-- <path> and git hash-object reproduces bcdd2490... exactly. A verified artifact is not what moves to satisfy a constant. 2. checkins/2026-10-19-zephyrus-sovereignty.md -- RENAMED, ledgered. Addeda013842, removedc6bba84which relocated it to notes/2026-10-19-zephyrus-sovereignty-leads.md (his reply-77 fix). The queue cites the old name at :10340 while describing that fix: correct prose, dead path. 3. checkins/2026-10-21-spark.md -- TARGET, the seventh class. Cited by replies/2026-10-21-to-spark-06.md:26 in an imperative ("Post ... (Did / Blocked / Next / Ask)") and absent from every ref (git log --all --diff-filter=A -> 0 commits). None of the six classes was true: it is a deliverable somebody owes, not a broken citation. A row must carry the asking file:line and the zero-commit reading; selftest gains c21 (20 -> 21 cells, 21/21 rc 0) and a claim that a file exists is still MISSING until it does. docs/TOOLS.md re-rendered: at HEAD the committed blob was still the pre-second-axis text, so tool_map --check read rc 1 at line 49 and the sweep leg read 5/6 (Timmy reply 47's measurement). git show HEAD:docs/TOOLS.md now grades rc 0 against tool_map --check. Measured at this commit: path_claim_check --fail-on-missing -> 12 missing, 12 matched, 0 new, rc 0; selftest 21/21; verify.py --repo . 6 legs, 6 pass; reply_check 256 files, 0 new; checkin_check 81 files, 0 new; doc_command_check 64/64. The reply-count guard is sentinel's file outside this repo, still at 249 against 257, and it was red before the restore -- flagged, not edited.
619 lines
30 KiB
Python
619 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""path_claim_check.py -- does every in-namespace path this archive names exist
|
|
at this revision?
|
|
|
|
WHY THIS EXISTS
|
|
`citation_resolve_check.py` grades `<path>:<line>` citations: it can resolve
|
|
them because a line number makes the claim decidable. A path named WITHOUT a
|
|
line -- "`recoveries/foo.md` (10,232 B)", "the record `docs/bar.md`" -- is the
|
|
same kind of claim with one fewer field, and nothing here graded it. The
|
|
defect is on the record: checkins/2026-10-19-volt.md records a report that
|
|
"named `check.sh` and `files.sha256`" and never wrote them, and the ledger's
|
|
rule is that a reader must be able to go and look. A path a reader cannot
|
|
open is a citation that fails the only test a stranger can run.
|
|
|
|
The measurement that made this tool possible also bounded it. Over the whole
|
|
corpus, most distinct path tokens are not candidates for "this archive holds
|
|
that file" at all: they begin with a component this repo does not track --
|
|
scratch trees (`after/verify.txt`), host artifacts (`/root/crucible/...`),
|
|
other repositories, served routes and URLs. Grading those as missing would be
|
|
hundreds of false defects, so they are COUNTED and never graded. What is left
|
|
is small enough for a reader: every MISSING path at the revision the ledger
|
|
was last reviewed against is in `tools/path_claim_baseline.tsv` with the class
|
|
that explains it, and `--fail-on-missing` fails on any the ledger does not
|
|
name -- so a revision that moves the corpus reds the gate until a reader has
|
|
looked. The census print is what says which revision that was.
|
|
|
|
This docstring deliberately carries no census figures. The first draft did,
|
|
and the leading-slash fix in the matcher moved every one of them (1,024
|
|
distinct tokens became 1,036) -- the defect class this archive grades as a
|
|
stale claim. The tool prints its own census on every run; that print is the
|
|
only place those numbers live.
|
|
|
|
WHAT IT MEASURES -- one verdict per DISTINCT path (unit: distinct paths)
|
|
EXISTS the path is in the tree at --rev.
|
|
MISSING the path's first component IS a tracked top-level entry, so the
|
|
prose is naming something inside this repository, and the tree at
|
|
--rev does not hold it. This is the graded class.
|
|
EXTERNAL the first component is not tracked here (scratch tree, host path,
|
|
URL, other repo). OUT OF SCOPE: the path may be perfectly true and
|
|
this tool cannot see it. Counted, never graded -- the same
|
|
vocabulary `citation_resolve_check.py` uses for EXTERNAL_PATH.
|
|
TEMPLATE the path carries a placeholder (`YYYY`, `<name>`, `{n}`), so it is
|
|
a pattern rather than a claim about one file.
|
|
|
|
Occurrences are counted beside distinct paths, because one wrong path cited
|
|
16 times is one defect and 16 chances for a reader to trip on it.
|
|
|
|
A FENCED BLOCK IS QUOTED MATERIAL, NOT THIS DOCUMENT'S PROSE -- the rule
|
|
`queue_index.py`, `checkin_check.py` and `citation_resolve_check.py` already
|
|
apply to headings and citations. A fenced listing that quotes a path is not
|
|
the containing document asserting that path exists. Skipped lines are printed
|
|
on every run, never silently dropped.
|
|
|
|
THE BASELINE, AND WHY THE FIRST CENSUS IS ITS LEDGER
|
|
`<path>\\t<CLASS>\\t<reason>`, one row per reviewed missing path, class from a
|
|
closed set (PLANTED, NOT_ADDED, RENAMED, SHORT_NAME, FOREIGN_TREE, EXAMPLE,
|
|
TARGET).
|
|
A row is a stated reading, not an exemption: `--fail-on-missing` fails only on
|
|
a MISSING path the ledger does not name, and every tolerated row is still
|
|
PRINTED with its class on every run, so a reader sees the tolerated rows
|
|
rather than trusting that there are none. A ledger row whose path now exists
|
|
is reported CLEARED, and a row naming a path the census no longer produces is
|
|
reported as a dead row -- the ledger cannot rot invisibly.
|
|
|
|
TARGET is the seventh class, added 2026-10-21 for the case the first six did
|
|
not cover: a path named in an IMPERATIVE sentence -- "Post
|
|
`checkins/2026-10-21-spark.md` (Did / Blocked / Next / Ask)" -- which asks its
|
|
owner to create the file rather than asserting that one exists. `git log --all
|
|
--diff-filter=A -- checkins/2026-10-21-spark.md` returns 0 commits, so no ref
|
|
ever held it, and the artifact is genuinely owed; but a repository whose
|
|
deliverables cannot be named without reddening the census teaches writers to
|
|
stop naming them, which is the worse failure. The row must name the asking
|
|
file and line and the zero-commit reading, so it is a citation a reader can
|
|
check rather than a word that closes a red. A claim that something EXISTS is
|
|
still MISSING until the file does.
|
|
|
|
EXIT CODES
|
|
0 the census ran. Without --fail-on-missing a MISSING path is not a fault.
|
|
1 --fail-on-missing and at least one MISSING path is not in the baseline, or
|
|
names a class the ledger does not tolerate.
|
|
2 could not grade: not a git work tree, a revision that does not resolve, a
|
|
corpus with no markdown, a NAMED baseline that cannot be read or names an
|
|
unknown class. Never 0, and never a silent empty census. A repo that
|
|
carries no ledger of its own is not this: `--baseline` defaults to
|
|
tools/path_claim_baseline.tsv resolved against --repo, and a tree without
|
|
that file is graded with no ledger after one note on stderr.
|
|
|
|
USAGE
|
|
python3 path_claim_check.py [--repo DIR] [--rev HEAD] [--corpus PATH...]
|
|
[--json OUT] [--only missing|exists|external|template|all] [--context]
|
|
[--fail-on-missing] [--baseline PATH]
|
|
--baseline defaults to tools/path_claim_baseline.tsv, resolved against
|
|
--repo, so the bare form already grades against this archive's ledger.
|
|
python3 path_claim_check.py --selftest # 21 cells
|
|
python3 path_claim_check.py --repo . --fail-on-missing
|
|
"""
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
DEFAULT_REPO = "/root/troupe-standup"
|
|
|
|
# The ledger this archive carries, REPO-relative: resolved against --repo, not
|
|
# against the caller's cwd, so a bare run against a clone reads that clone's
|
|
# ledger. Until 2026-10-21 this was advertised in USAGE and never applied --
|
|
# `--baseline` had no default, so a bare invocation graded against an empty
|
|
# ledger and, under --fail-on-missing, returned rc 1 for an archive whose own
|
|
# rows excuse seven of the ten MISSING paths. A default a reader can cite and
|
|
# the code does not apply is the worst kind: it makes the tool lie in the
|
|
# direction of a defect.
|
|
DEFAULT_BASELINE = "tools/path_claim_baseline.tsv"
|
|
|
|
# The extensions this archive actually names. A closed list, for the reason
|
|
# citation_resolve_check.py gives: an open `\\.[a-z]+` swallows clock times,
|
|
# decimals and domain names.
|
|
EXT = (r"md|markdown|py|json|txt|js|mjs|cjs|sh|bash|yml|yaml|html|css|csv|tsv|"
|
|
r"jsonl|sql|db|sqlite|age|wav|mp3|mp4|gz|tgz|toml|cfg|ini|log|out|bak")
|
|
|
|
# At least one '/' is REQUIRED: a bare filename ("index.html") is not a claim
|
|
# about a location, and the archive names dozens of those for pages it does not
|
|
# hold. The trailing guard stops `x.md` from matching the front of `x.md5`.
|
|
PATH = re.compile(r"(?P<path>/?(?:[A-Za-z0-9_.@+-]+/)+[A-Za-z0-9_.@+-]+\.(?:%s))"
|
|
r"(?![A-Za-z0-9])" % EXT)
|
|
FENCE = re.compile(r"^\s*(```|~~~)")
|
|
HOST = re.compile(r"^(?:/|~/|\.\./)")
|
|
TEMPLATE = re.compile(r"YYYY|<[^>]*>|\{[^}]*\}|NNN")
|
|
STRIP = "`*'\"()[]<>,;:."
|
|
|
|
# The closed class set a baseline row may name. Each is a reason a MISSING path
|
|
# is not a live defect; adding one is a deliberate edit to this tuple.
|
|
# EXAMPLE was added after the tool's own construction record landed and the next
|
|
# census found 3 new MISSING paths -- two quoted metasyntactic illustrations
|
|
# ("recoveries/foo.md", "the record docs/bar.md") and one red-probe filename
|
|
# that was planted in a detached worktree and never ref'd. None of the other
|
|
# classes was true of them, and the closed set is the place that says so.
|
|
# TARGET was added 2026-10-21 by the same rule: the census's next tick found
|
|
# `checkins/2026-10-21-spark.md`, cited by replies/2026-10-21-to-spark-06.md:26
|
|
# in an imperative ("Post ... (Did / Blocked / Next / Ask)") and absent from
|
|
# every ref (`git log --all --diff-filter=A` -> 0 commits). It is not a broken
|
|
# citation; it is a deliverable somebody owes, which is a different thing.
|
|
BASELINE_CLASSES = ("PLANTED", "NOT_ADDED", "RENAMED", "SHORT_NAME",
|
|
"FOREIGN_TREE", "EXAMPLE", "TARGET")
|
|
VERDICTS = ("EXISTS", "MISSING", "EXTERNAL", "TEMPLATE")
|
|
|
|
|
|
class CannotGrade(Exception):
|
|
"""An input this tool refuses to grade: the caller turns it into rc 2."""
|
|
|
|
|
|
def git(repo, *args):
|
|
return subprocess.run(["git", "-C", repo, *args], capture_output=True)
|
|
|
|
|
|
def precondition(repo, rev):
|
|
"""None when `repo` is a git work tree carrying `rev`, else why not.
|
|
|
|
Every reader below swallows git's status, so an unreadable repo or a
|
|
mistyped revision would come back as an empty tree and print a clean census
|
|
of nothing -- the silent-pass class selftest_sweep.py's malformed axis
|
|
exists to catch.
|
|
"""
|
|
if not os.path.isdir(repo):
|
|
return "cannot read repo %s: no such directory" % repo
|
|
p = git(repo, "rev-parse", "--is-inside-work-tree")
|
|
if p.returncode != 0 or p.stdout.strip() != b"true":
|
|
return "cannot read repo %s: not a git work tree" % repo
|
|
q = git(repo, "rev-parse", "--verify", rev + "^{commit}")
|
|
if q.returncode != 0:
|
|
why = q.stderr.decode("utf-8", "replace").strip().splitlines()
|
|
return "cannot read %s at %s: %s" % (repo, rev, why[-1] if why else "git rev-parse failed")
|
|
return None
|
|
|
|
|
|
def clean(token):
|
|
"""Strip the punctuation a path wears when it sits in prose or in a span."""
|
|
p = token.strip(STRIP)
|
|
while p.startswith("./"):
|
|
p = p[2:].strip(STRIP)
|
|
return p
|
|
|
|
|
|
def classify(path, tracked, tops):
|
|
if TEMPLATE.search(path):
|
|
return "TEMPLATE"
|
|
if HOST.match(path) or path.split("/")[0] not in tops:
|
|
return "EXTERNAL"
|
|
return "EXISTS" if path in tracked else "MISSING"
|
|
|
|
|
|
def fence_mask(text):
|
|
"""1-based line numbers that are a fence marker or sit inside a fence."""
|
|
out, in_fence = set(), False
|
|
for i, raw in enumerate(text.split("\n"), 1):
|
|
if FENCE.match(raw):
|
|
out.add(i)
|
|
in_fence = not in_fence
|
|
continue
|
|
if in_fence:
|
|
out.add(i)
|
|
return out
|
|
|
|
|
|
def scan_text(text, where, tracked, tops):
|
|
"""Rows for one document -> ({path: row}, counter).
|
|
|
|
`where` is the document's name, used only to build the first-cite handle.
|
|
Line numbers are the document's own, so a row can be read back at --rev.
|
|
"""
|
|
rows, stats = {}, collections.Counter()
|
|
mask = fence_mask(text)
|
|
stats["fenced_lines"] = len(mask)
|
|
for i, line in enumerate(text.split("\n"), 1):
|
|
if i in mask:
|
|
continue
|
|
for m in PATH.finditer(line):
|
|
p = clean(m.group("path"))
|
|
if not p:
|
|
continue
|
|
stats["occurrences"] += 1
|
|
row = rows.get(p)
|
|
if row is None:
|
|
row = rows[p] = {"path": p, "mentions": 0,
|
|
"first": "%s:%d" % (where, i),
|
|
"context": line.strip()[:180],
|
|
"verdict": classify(p, tracked, tops)}
|
|
row["mentions"] += 1
|
|
return rows, stats
|
|
|
|
|
|
def scan(repo, rev, corpus, store):
|
|
all_rows, stats = {}, collections.Counter()
|
|
for f in corpus:
|
|
text = store.read(f).decode("utf-8", "replace")
|
|
rows, st = scan_text(text, f, store.tree, store.tops)
|
|
stats.update(st)
|
|
for p, row in rows.items():
|
|
if p in all_rows:
|
|
all_rows[p]["mentions"] += row["mentions"]
|
|
else:
|
|
all_rows[p] = row
|
|
return all_rows, stats
|
|
|
|
|
|
class Store:
|
|
def __init__(self, repo, rev):
|
|
self.repo, self.rev = repo, rev
|
|
self.tree = set(git(repo, "ls-tree", "-r", "--name-only", rev)
|
|
.stdout.decode("utf-8", "replace").splitlines())
|
|
self.tops = {p.split("/")[0] for p in self.tree}
|
|
|
|
def read(self, path):
|
|
p = git(self.repo, "show", "%s:%s" % (self.rev, path))
|
|
if p.returncode != 0:
|
|
raise CannotGrade("cannot read %s at %s" % (path, self.rev))
|
|
return p.stdout
|
|
|
|
|
|
def load_baseline(path):
|
|
"""`<path>\\t<CLASS>\\t<reason>` -> {path: (class, reason)}. Unreadable is rc 2."""
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
text = fh.read()
|
|
except OSError as exc:
|
|
raise CannotGrade("cannot read baseline %s: %s" % (path, exc))
|
|
except UnicodeDecodeError as exc:
|
|
raise CannotGrade("cannot read baseline %s: not UTF-8: %s" % (path, exc))
|
|
out = {}
|
|
for n, line in enumerate(text.splitlines(), 1):
|
|
s = line.rstrip("\r")
|
|
if not s.strip() or s.lstrip().startswith("#"):
|
|
continue
|
|
parts = s.split("\t")
|
|
if len(parts) != 3:
|
|
raise CannotGrade("cannot read baseline %s:%d: not "
|
|
"<path>\\t<CLASS>\\t<reason>: %r" % (path, n, s))
|
|
name, cls, reason = (x.strip() for x in parts)
|
|
if not name or not reason:
|
|
raise CannotGrade("cannot read baseline %s:%d: empty path or reason: %r"
|
|
% (path, n, s))
|
|
if cls not in BASELINE_CLASSES:
|
|
raise CannotGrade("cannot read baseline %s:%d: unknown class %r "
|
|
"(known: %s)" % (path, n, cls, ", ".join(BASELINE_CLASSES)))
|
|
out[name] = (cls, reason)
|
|
return out
|
|
|
|
|
|
def apply_baseline(rows, ledger):
|
|
"""Attach the ledger to each MISSING row; report what moved on both sides."""
|
|
missing = {p: r for p, r in rows.items() if r["verdict"] == "MISSING"}
|
|
for p, row in missing.items():
|
|
if p in ledger:
|
|
row["baseline"] = {"known": True, "class": ledger[p][0],
|
|
"reason": ledger[p][1], "new": False}
|
|
else:
|
|
row["baseline"] = {"known": False, "class": None, "reason": None, "new": True}
|
|
cleared = sorted(p for p in ledger if p in rows and rows[p]["verdict"] == "EXISTS")
|
|
dead = sorted(p for p in ledger if p not in rows)
|
|
return {"entries": len(ledger), "matched": sum(1 for r in missing.values()
|
|
if r["baseline"]["known"]),
|
|
"new": sorted(p for p, r in missing.items() if r["baseline"]["new"]),
|
|
"cleared": cleared, "dead": dead,
|
|
"tolerated": collections.Counter(r["baseline"]["class"]
|
|
for r in missing.values()
|
|
if r["baseline"]["known"])}
|
|
|
|
|
|
def report(rows, stats, rev, sha, corpus, ledger, path, only, context):
|
|
counts = collections.Counter(r["verdict"] for r in rows.values())
|
|
print("path_claim_check: %s at %s (%s)" % ("repo", rev, sha[:7]))
|
|
print("matcher: /?(?:[A-Za-z0-9_.@+-]+/)+[A-Za-z0-9_.@+-]+\\.(closed ext list), "
|
|
"at least one '/' required")
|
|
print(" graded only when the first component is a tracked top-level entry; "
|
|
"fenced blocks skipped")
|
|
print("corpus: %d markdown file(s)" % len(corpus))
|
|
print("paths: %d distinct / %d occurrence(s) (unit: distinct paths; "
|
|
"occurrences beside)" % (len(rows), stats["occurrences"]))
|
|
for v in VERDICTS:
|
|
print(" %-9s %4d distinct" % (v, counts[v]))
|
|
print("fenced skipped: %d line(s)" % stats["fenced_lines"])
|
|
if ledger is not None:
|
|
print("baseline %s: %d entries, %d matched, %d new, %d cleared, %d dead row(s)"
|
|
% (path, ledger["entries"], ledger["matched"], len(ledger["new"]),
|
|
len(ledger["cleared"]), len(ledger["dead"])))
|
|
for cls, n in sorted(ledger["tolerated"].items()):
|
|
print(" tolerated %-13s %d" % (cls, n))
|
|
for p in ledger["cleared"]:
|
|
print(" CLEARED %s -- ledger row, now in the tree; the row can go" % p)
|
|
for p in ledger["dead"]:
|
|
print(" DEAD_ROW %s -- ledger row, no longer in the census; the row can go" % p)
|
|
|
|
sel = {"missing": lambda r: r["verdict"] == "MISSING",
|
|
"exists": lambda r: r["verdict"] == "EXISTS",
|
|
"external": lambda r: r["verdict"] == "EXTERNAL",
|
|
"template": lambda r: r["verdict"] == "TEMPLATE",
|
|
"all": lambda r: True}[only]
|
|
show = sorted((r for r in rows.values() if sel(r)),
|
|
key=lambda r: (-r["mentions"], r["path"]))
|
|
print("--- %s: %d distinct path(s) (showing first 300) ---" % (only, len(show)))
|
|
for r in show[:300]:
|
|
tag = ""
|
|
if r["verdict"] == "MISSING" and r.get("baseline"):
|
|
b = r["baseline"]
|
|
tag = (" [%s: %s]" % (b["class"], b["reason"])) if b["known"] \
|
|
else " [NEW -- not in the baseline]"
|
|
print(" %3dx %-58s first %s%s" % (r["mentions"], r["path"], r["first"], tag))
|
|
if context:
|
|
print(" ...%s..." % r["context"])
|
|
|
|
|
|
def resolve_baseline(repo, path):
|
|
"""The ledger to grade against, or None when this repo carries none.
|
|
|
|
`path` is repo-relative by contract (DEFAULT_BASELINE), so it is joined onto
|
|
`repo` before the existence test: a bare run from another cwd must read the
|
|
tree it is grading, never a file beside the caller. An absolute path is
|
|
taken as given. A caller who NAMED a ledger that is not there gets the path
|
|
back unchanged, so load_baseline refuses it with rc 2 -- a mistyped ledger
|
|
must never read as "no ledger". A repo that simply carries none (a fixture
|
|
tree) is a stated reading: one note on stderr, no ledger, and the census
|
|
still prints, because the count is the finding.
|
|
"""
|
|
if not path:
|
|
return None
|
|
cand = path if os.path.isabs(path) else os.path.join(repo, path)
|
|
if os.path.exists(cand):
|
|
return cand
|
|
if path == DEFAULT_BASELINE:
|
|
print("note: no ledger at %s -- every MISSING path counts as new" % cand,
|
|
file=sys.stderr)
|
|
return None
|
|
return cand
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--repo", default=DEFAULT_REPO)
|
|
ap.add_argument("--rev", default="HEAD")
|
|
ap.add_argument("--corpus", nargs="*")
|
|
ap.add_argument("--json")
|
|
ap.add_argument("--only", choices=["missing", "exists", "external", "template",
|
|
"all"], default="missing")
|
|
ap.add_argument("--context", action="store_true")
|
|
ap.add_argument("--fail-on-missing", action="store_true")
|
|
ap.add_argument("--baseline", default=DEFAULT_BASELINE,
|
|
help="ledger to grade MISSING rows against (default: "
|
|
"%s, resolved against --repo)" % DEFAULT_BASELINE)
|
|
ap.add_argument("--selftest", action="store_true")
|
|
a = ap.parse_args()
|
|
|
|
if a.selftest:
|
|
return selftest()
|
|
|
|
problem = precondition(a.repo, a.rev)
|
|
if problem:
|
|
print(problem, file=sys.stderr)
|
|
return 2
|
|
try:
|
|
store = Store(a.repo, a.rev)
|
|
sha = git(a.repo, "rev-parse", a.rev).stdout.decode().strip()
|
|
corpus = a.corpus if a.corpus is not None else \
|
|
sorted(p for p in store.tree if p.endswith((".md", ".markdown")))
|
|
if not corpus:
|
|
raise CannotGrade("cannot grade: no markdown corpus at %s in %s"
|
|
% (a.rev, a.repo))
|
|
ledger = resolve_baseline(a.repo, a.baseline)
|
|
ledger = load_baseline(ledger) if ledger else None
|
|
rows, stats = scan(a.repo, a.rev, corpus, store)
|
|
except CannotGrade as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
return 2
|
|
led = apply_baseline(rows, ledger) if ledger is not None else None
|
|
if led is None:
|
|
# No ledger: every MISSING path is new, and the count is the finding.
|
|
led_view = None
|
|
else:
|
|
led_view = led
|
|
report(rows, stats, a.rev, sha, corpus, led_view, a.baseline, a.only, a.context)
|
|
|
|
missing = [r for r in rows.values() if r["verdict"] == "MISSING"]
|
|
new = len(missing) if led is None else len(led["new"])
|
|
print("\npath_claim_check: %d distinct paths, %d missing, %d new against the baseline"
|
|
% (len(rows), len(missing), new))
|
|
if a.json:
|
|
try:
|
|
with open(a.json, "w") as fh:
|
|
json.dump({"repo": a.repo, "rev": a.rev, "sha": sha, "corpus": corpus,
|
|
"stats": dict(stats), "baseline": led,
|
|
"paths": sorted(rows.values(), key=lambda r: r["path"])},
|
|
fh, indent=1)
|
|
except OSError as exc:
|
|
print("cannot write --json " + a.json + ": " + str(exc), file=sys.stderr)
|
|
return 2
|
|
print("wrote %s" % a.json)
|
|
if a.fail_on_missing and new:
|
|
print("FAIL: %d missing path(s) the baseline does not name" % new)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# --------------------------------------------------------------- self-test
|
|
# The fixtures are graded by the same scan_text() the corpus is, so a cell that
|
|
# passes here is a statement about the shipped matcher, not about a copy of it.
|
|
TRACKED = {"docs/real.md", "tools/t.py", "notes/n.md", "WORK-QUEUE.md"}
|
|
TOPS = {"docs", "tools", "notes"}
|
|
|
|
|
|
def _verdicts(text):
|
|
rows, _ = scan_text(text, "fixture.md", TRACKED, TOPS)
|
|
return {p: r["verdict"] for p, r in rows.items()}
|
|
|
|
|
|
def _rows(text):
|
|
return scan_text(text, "fixture.md", TRACKED, TOPS)[0]
|
|
|
|
|
|
def selftest():
|
|
n = bad = 0
|
|
|
|
def cell(name, ok, detail=""):
|
|
nonlocal n, bad
|
|
n += 1
|
|
bad += 0 if ok else 1
|
|
print("%s %s%s" % ("PASS" if ok else "FAIL", name,
|
|
"" if ok else " <- " + detail))
|
|
|
|
cell("c1 a tracked path is EXISTS",
|
|
_verdicts("see docs/real.md for the table") == {"docs/real.md": "EXISTS"},
|
|
str(_verdicts("see docs/real.md for the table")))
|
|
cell("c2 an in-namespace path not in the tree is MISSING",
|
|
_verdicts("the record `docs/absent.md` was written") == {"docs/absent.md": "MISSING"},
|
|
str(_verdicts("the record `docs/absent.md` was written")))
|
|
cell("c3 a component this repo does not track is EXTERNAL, not MISSING",
|
|
_verdicts("kept at crucible/q51/helm-selftest-q51.py") ==
|
|
{"crucible/q51/helm-selftest-q51.py": "EXTERNAL"},
|
|
str(_verdicts("kept at crucible/q51/helm-selftest-q51.py")))
|
|
cell("c4 an absolute host path is EXTERNAL",
|
|
_verdicts("under /root/crucible/q39/gate.py today") ==
|
|
{"/root/crucible/q39/gate.py": "EXTERNAL"},
|
|
str(_verdicts("under /root/crucible/q39/gate.py today")))
|
|
cell("c5 a placeholder path is TEMPLATE, not MISSING",
|
|
_verdicts("one file per day: checkins/YYYY-MM-DD-volt.md") ==
|
|
{"checkins/YYYY-MM-DD-volt.md": "TEMPLATE"},
|
|
str(_verdicts("one file per day: checkins/YYYY-MM-DD-volt.md")))
|
|
cell("c6 a fenced path is quoted material, not a claim",
|
|
_verdicts("```\ndocs/absent.md\n```") == {},
|
|
str(_verdicts("```\ndocs/absent.md\n```")))
|
|
cell("c7 the fence closes: the same path in prose is graded again",
|
|
_verdicts("```\ndocs/absent.md\n```\nsee docs/absent.md") ==
|
|
{"docs/absent.md": "MISSING"},
|
|
str(_verdicts("```\ndocs/absent.md\n```\nsee docs/absent.md")))
|
|
cell("c8 a bare filename with no directory is not a path claim",
|
|
_verdicts("the page index.html and blocks/index.html") ==
|
|
{"blocks/index.html": "EXISTS" if "blocks/index.html" in TRACKED else "EXTERNAL"},
|
|
str(_verdicts("the page index.html and blocks/index.html")))
|
|
cell("c9 trailing punctuation and backticks are stripped",
|
|
list(_verdicts("`docs/absent.md`.").keys()) == ["docs/absent.md"],
|
|
str(_verdicts("`docs/absent.md`.")))
|
|
r = _rows("docs/absent.md once, then docs/absent.md again")
|
|
cell("c10 occurrences aggregate onto one row with its first cite",
|
|
len(r) == 1 and r["docs/absent.md"]["mentions"] == 2
|
|
and r["docs/absent.md"]["first"] == "fixture.md:1", str(r))
|
|
|
|
# --- the CLI contract, on a real fixture repo -------------------------
|
|
import tempfile
|
|
td = tempfile.mkdtemp(prefix="pathclaim-")
|
|
repo = os.path.join(td, "repo")
|
|
os.makedirs(os.path.join(repo, "docs"))
|
|
os.makedirs(os.path.join(repo, "tools"))
|
|
with open(os.path.join(repo, "docs", "real.md"), "w") as fh:
|
|
fh.write("real\n")
|
|
with open(os.path.join(repo, "tools", "t.py"), "w") as fh:
|
|
fh.write("# t\n")
|
|
with open(os.path.join(repo, "WORK-QUEUE.md"), "w") as fh:
|
|
fh.write("the record `docs/absent.md` and the real docs/real.md\n")
|
|
subprocess.run(["git", "init", "-q", repo], capture_output=True)
|
|
subprocess.run(["git", "-C", repo, "add", "-A"], capture_output=True)
|
|
subprocess.run(["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t",
|
|
"commit", "-qm", "fixture"], capture_output=True)
|
|
|
|
def run(*args):
|
|
p = subprocess.run([sys.executable, os.path.abspath(__file__), *args],
|
|
capture_output=True, text=True)
|
|
return p.returncode, p.stdout + p.stderr
|
|
|
|
rc, out = run("--repo", repo)
|
|
cell("c11 the census alone is rc 0 even with a missing path",
|
|
rc == 0 and "1 missing" in out, "rc=%d %s" % (rc, out[-200:]))
|
|
rc, out = run("--repo", repo, "--fail-on-missing")
|
|
cell("c12 --fail-on-missing is rc 1 on an unbaselined missing path",
|
|
rc == 1 and "FAIL: 1 missing path" in out, "rc=%d %s" % (rc, out[-200:]))
|
|
bl = os.path.join(td, "baseline.tsv")
|
|
with open(bl, "w") as fh:
|
|
fh.write("# fixture\n" + "docs/absent.md\tPLANTED\tfixture row\n")
|
|
rc, out = run("--repo", repo, "--fail-on-missing", "--baseline", bl)
|
|
cell("c13 a baselined missing path is tolerated, and still printed",
|
|
rc == 0 and "PLANTED" in out and "1 matched" in out,
|
|
"rc=%d %s" % (rc, out[-300:]))
|
|
with open(os.path.join(td, "cleared.tsv"), "w") as fh:
|
|
fh.write("docs/real.md\tRENAMED\tfixture: the row is now in the tree\n")
|
|
rc, out = run("--repo", repo, "--baseline", os.path.join(td, "cleared.tsv"))
|
|
cell("c14 a ledger row whose path now exists is CLEARED and named",
|
|
rc == 0 and "CLEARED" in out and "docs/real.md" in out,
|
|
"rc=%d %s" % (rc, out[-300:]))
|
|
rc, out = run("--repo", td)
|
|
cell("c15 a directory that is not a git work tree is rc 2",
|
|
rc == 2 and "not a git work tree" in out, "rc=%d %s" % (rc, out[-200:]))
|
|
rc, out = run("--repo", repo, "--rev", "nosuchrev")
|
|
cell("c16 a revision that does not resolve is rc 2",
|
|
rc == 2 and "cannot read" in out, "rc=%d %s" % (rc, out[-200:]))
|
|
empty = os.path.join(td, "empty")
|
|
os.makedirs(empty)
|
|
with open(os.path.join(empty, "a.txt"), "w") as fh:
|
|
fh.write("no markdown here\n")
|
|
subprocess.run(["git", "init", "-q", empty], capture_output=True)
|
|
subprocess.run(["git", "-C", empty, "add", "-A"], capture_output=True)
|
|
subprocess.run(["git", "-C", empty, "-c", "user.email=t@t", "-c", "user.name=t",
|
|
"commit", "-qm", "fixture"], capture_output=True)
|
|
rc, out = run("--repo", empty)
|
|
cell("c17 a corpus with no markdown is rc 2, never an empty clean census",
|
|
rc == 2 and "no markdown corpus" in out, "rc=%d %s" % (rc, out[-200:]))
|
|
|
|
# --- the ledger's closed set is enforced, not decorative ----------------
|
|
badcls = os.path.join(td, "badclass.tsv")
|
|
with open(badcls, "w") as fh:
|
|
fh.write("docs/absent.md\tMADE_UP\tfixture: a class the tool does not know\n")
|
|
rc, out = run("--repo", repo, "--fail-on-missing", "--baseline", badcls)
|
|
cell("c18 a ledger row naming a class outside the closed set is rc 2",
|
|
rc == 2 and "unknown class" in out and "MADE_UP" in out,
|
|
"rc=%d %s" % (rc, out[-300:]))
|
|
excls = os.path.join(td, "example.tsv")
|
|
with open(excls, "w") as fh:
|
|
fh.write("docs/absent.md\tEXAMPLE\tfixture: a metasyntactic illustration\n")
|
|
rc, out = run("--repo", repo, "--fail-on-missing", "--baseline", excls)
|
|
cell("c19 the EXAMPLE class is accepted and its row still printed",
|
|
rc == 0 and "tolerated EXAMPLE" in out and "1 matched" in out,
|
|
"rc=%d %s" % (rc, out[-300:]))
|
|
tgtcls = os.path.join(td, "target.tsv")
|
|
with open(tgtcls, "w") as fh:
|
|
fh.write("docs/absent.md\tTARGET\tfixture: an owed deliverable\n")
|
|
rc, out = run("--repo", repo, "--fail-on-missing", "--baseline", tgtcls)
|
|
cell("c21 the TARGET class is accepted and its row still printed",
|
|
rc == 0 and "tolerated TARGET" in out and "1 matched" in out,
|
|
"rc=%d %s" % (rc, out[-300:]))
|
|
|
|
# --- the default ledger is the one the tree carries ---------------------
|
|
# Until 2026-10-21 `--baseline` had NO default while USAGE advertised one:
|
|
# a bare run graded against an empty ledger, so `--fail-on-missing` returned
|
|
# rc 1 for a tree whose own rows excused the path. This pins bare == named.
|
|
with open(os.path.join(repo, "tools", "path_claim_baseline.tsv"), "w") as fh:
|
|
fh.write("# fixture: the ledger this tree carries\n"
|
|
"docs/absent.md\tPLANTED\tfixture row, carried by the tree\n")
|
|
subprocess.run(["git", "-C", repo, "add", "-A"], capture_output=True)
|
|
subprocess.run(["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t",
|
|
"commit", "-qm", "ledger"], capture_output=True)
|
|
|
|
def summary(text):
|
|
return [ln for ln in text.splitlines() if ln.startswith("path_claim_check:")]
|
|
|
|
bare_rc, bare_out = run("--repo", repo, "--fail-on-missing")
|
|
named_rc, named_out = run("--repo", repo, "--fail-on-missing", "--baseline",
|
|
os.path.join(repo, "tools", "path_claim_baseline.tsv"))
|
|
cell("c20 the repo's own ledger is the default: bare run == named run",
|
|
bare_rc == named_rc == 0 and summary(bare_out) == summary(named_out)
|
|
and "1 matched" in bare_out,
|
|
"bare rc=%d %r | named rc=%d %r"
|
|
% (bare_rc, summary(bare_out), named_rc, summary(named_out)))
|
|
|
|
print("selftest: %d cells, %d passed, %d failed" % (n, n - bad, bad))
|
|
return 0 if bad == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|