From 6673ad51fcb55d514d8d1c152b1d7794c185428a Mon Sep 17 00:00:00 2001 From: vu1n <2105991+vu1n@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:12:08 +0000 Subject: [PATCH 1/3] scripts: re-vendor featuremap.py from vu1n/dev@065e973, pinned --- scripts/featuremap.py | 235 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 233 insertions(+), 2 deletions(-) diff --git a/scripts/featuremap.py b/scripts/featuremap.py index e5afead..6140963 100755 --- a/scripts/featuremap.py +++ b/scripts/featuremap.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# vendored from vu1n/dev@065e973785d468d06812f0fc60c74b09d5915bb3; sha256 9a5862396bfe1a5e9a392d322e7a94cc7ca337c227e806bf9944b34be3343043 """A repo's feature map: which code and tests implement each feature, and whether the map's pointers still resolve. @@ -16,14 +17,25 @@ - "test/search.test.ts:returns title and body matches" --- - features [--base REF] [FILE ...] the features a change touches, with their tests and Gotchas + features [--base REF] [FILE ...] the features a change touches, with their tests and Gotchas, + and whether their paths changed since their file last did check exit 1 when a pointer no longer resolves -Stdlib only, so a repo can vendor it as scripts/featuremap.py and run it in CI. +A feature a member sees can add a `## User flow`: a ```mermaid flowchart of their path, drawn as a +statechart, then a proof table with one row per node, `| `id` | label | proof |`. The label is ✓ (a +test or a drive asserts it: the proof names a `file:test name` or a `drive.sh `), 👁 (only +seen on a screen: the node shows `img: "shot:.png"`), or gap (nothing covers it: the node is in +the flow's `gap` class). `check` fails when a node has no row, a row names no node, a label and its +node disagree, a pointer doesn't resolve, a shot isn't under `shots//`, a node can't be +reached from the start, or a node with no arrow out isn't in an end class (done, nothing, stopped). + +Stdlib only, so a repo can vendor it as scripts/featuremap.py and run it in CI. A user flow is +parsed by Pele, the Mermaid parser, run with node and imported from beside this script. """ from __future__ import annotations import argparse +import hashlib import json import re import subprocess @@ -165,15 +177,225 @@ def check(root: Path, features: list[Feature]) -> tuple[list[str], list[str]]: problems.append(f"{where}: {kind} {ref!r}: {name!r} not found in the file") if not f.tests: notes.append(f"{where}: lists no tests") + problems += [f"{where}: user flow: {p}" for p in check_flow(root, f)] return problems, notes +FLOW_LABELS = {"✓": "asserted", "👁": "observed", "gap": "gap"} + + +# Pele (obsidianmd/pele), the parser huddles-up draws flows with, reads the flowchart, so the check +# sees the diagram as it renders. It is imported from beside this script, never from the checked +# repo, so checking an untrusted branch runs none of its code. +PELE = """ +const { detectType, load, parse } = await import("pele/lazy"); +let source = ""; +for await (const chunk of process.stdin) source += chunk; +const type = detectType(source); +if (type !== "flowchart") throw new Error(`a user flow is a flowchart, not ${type ?? "unknown"}`); +await load(type); +const model = parse(source); +process.stdout.write(JSON.stringify({ + nodes: [...model.nodes.values()].map((n) => ({ + id: n.id, type: n.type ?? null, text: n.text ?? n.id, img: n.img ?? null, classes: n.classes ?? [], + })), + edges: model.edges.map((e) => ({ start: e.start, end: e.end, type: e.type })), +})); +""" + + +@dataclass +class FlowNode: + id: str + type: str | None # f-circ, round, diamond, hexagon, ..; None for one only an arrow names + text: str + img: str | None + classes: set[str] + + +def flow_model(source: str) -> tuple[dict[str, FlowNode], list[tuple[str, str]]]: + """The flowchart's nodes, in order of first mention, and its (from, to) edges, by Pele's parse. + Raises MapError when Pele can't run or the flowchart doesn't parse.""" + try: + run = subprocess.run(["node", "--input-type=module", "-e", PELE], cwd=Path(__file__).resolve().parent, input=source, + capture_output=True, text=True, timeout=60) + except FileNotFoundError: + raise MapError("a user flow is parsed by Pele, which needs node on PATH") from None + if run.returncode: + err = run.stderr.strip() + if "ERR_MODULE_NOT_FOUND" in err and "pele" in err: + raise MapError(f"a user flow is parsed by Pele: install `pele` where {Path(__file__).name} can import it") + lines = [l for l in err.splitlines() if l.strip() and not l.lstrip().startswith("at ")] + raise MapError("the flow doesn't parse: " + " ".join(lines[-4:])) + model = json.loads(run.stdout) + nodes = {n["id"]: FlowNode(n["id"], n["type"], n["text"], n["img"], set(n["classes"])) + for n in model["nodes"]} + edges: list[tuple[str, str]] = [] + for e in model["edges"]: + edges.append((e["start"], e["end"])) + if e["type"].startswith("double_"): # `<-->` runs both ways + edges.append((e["end"], e["start"])) + return nodes, edges + + +FLOW_ENDS = ("done", "nothing", "stopped") + + +def check_drive(root: Path, scenario: str) -> str | None: + """Why `drive.sh ` names no drive the repo can rerun, or None.""" + if not scenario: + return "names no scenario" + # Only committed files count: a local, ignored verify skill isn't there in CI or a fresh clone. + tracked = set(git(root, "ls-files", "-z", "--", ".claude/skills/verify-*/*", ".agents/skills/verify-*/*").split("\0")) + drives = sorted(root / t for t in tracked if t.endswith("/drive.sh") and (root / t).is_file()) + if not drives: + return "no committed verify-*/drive.sh in the repo" + # A verify skill names its scenarios on a `Scenarios:` line, in backticks, or in a documented + # `drive.sh ` command; prose elsewhere names none. + known: set[str] = set() + for d in drives: + doc = d.parent / "SKILL.md" + text = doc.read_text(errors="replace") if doc.relative_to(root).as_posix() in tracked and doc.is_file() else "" + for line in re.findall(r"^\s*Scenarios:(.*)$", text, re.M): + known.update(w for quoted in re.findall(r"`([^`]+)`", line) for w in quoted.split()) + # The whole token is the scenario (`send.v2`), less a sentence's closing punctuation. + known.update(t.rstrip(".,;:") for t in re.findall(r"drive\.sh\s+([^\s`'\"|()]+)", text)) + name = scenario.split()[0] + return None if name in known else f"no scenario {name!r} in the verify skill" + + +def check_flow(root: Path, f: Feature) -> list[str]: + """What a feature's `## User flow` gets wrong: every node a proof row, every row a node.""" + flow = f.section("User flow") + if not re.search(r"^## User flow\s*$", f.body, re.M): + return [] + fence = re.search(r"```mermaid\n(.*?)\n```", flow, re.S) + if not fence: + return ["no ```mermaid fence"] + try: + nodes, edges = flow_model(fence.group(1)) + except MapError as e: + return [str(e)] + if not nodes: + return ["the flow has no states"] + gaps = {node for node, n in nodes.items() if "gap" in n.classes} + rows: dict[str, tuple[str, str]] = {} + problems = [] + for cells in re.findall(r"^\|\s*`([^`\s|]+)`\s*\|\s*([^|]+?)\s*\|\s*(.+?)\s*\|\s*$", flow, re.M): + node, label, proof = cells + if node in rows: + problems.append(f"`{node}` has two proof rows") + rows[node] = (label, proof) + # The start is where the member is, and a decision (a `{diamond}`, not a `{{hexagon}}`) is a + # question; every other node is a claim. + # A start drawn as a statechart's filled dot is no screen, so the state it enters is the start. + dots = [node for node, n in nodes.items() if n.type == "f-circ"] + start = dots[0] if dots else next(iter(nodes), None) + where = {start} | {b for a, b in edges if a == start and dots} + for node, n in nodes.items(): + if node not in where and node not in rows and n.type != "diamond": + problems.append(f"`{node}` has no proof row") + # A statechart's rigor: every state is reachable from the start, and one with no way out is an + # end that says how it ended. + reached, todo = set(), [start] + while todo: + node = todo.pop() + if node is not None and node not in reached: + reached.add(node) + todo += [b for a, b in edges if a == node] + leaving = {a for a, _ in edges} + shots = (root / f.file.parent / "shots" / f.id).resolve() + for node, n in nodes.items(): + # Every node's shot resolves inside its feature's shots/, proof row or not. + shot = n.img[len("shot:"):] if n.img and n.img.startswith("shot:") else None + if shot is not None and not (shots / shot).resolve().is_relative_to(shots): + problems.append(f"`{node}`: shot {shot!r} is outside shots/{f.id}/") + elif shot is not None and not (shots / shot).is_file(): + problems.append(f"`{node}`: shot {shot!r} is not in shots/{f.id}/") + if node not in reached: + problems.append(f"`{node}` can't be reached from the start") + elif node not in leaving and not n.classes & set(FLOW_ENDS): + problems.append(f"`{node}` has no way out, so it is an end: put it in class {', '.join(FLOW_ENDS)}") + for node, (label, proof) in rows.items(): + n = nodes.get(node) + if n is None: + problems.append(f"row `{node}` names no node in the flow") + continue + if label not in FLOW_LABELS: + problems.append(f"`{node}`: label {label!r} is not ✓, 👁 or gap") + continue + if (label == "gap") != (node in gaps): + problems.append(f"`{node}`: labelled {label} but {'not ' if node not in gaps else ''}in the gap class") + if label != "gap" and not n.text.strip().startswith(label): + problems.append(f"`{node}`: labelled {label} but its text doesn't start with it") + if label == "👁" and not (n.img or "").startswith("shot:"): + problems.append(f"`{node}`: observed, but shows no shot") + # A gap names what is missing; its code spans are prose, not pointers. + refs = [] if label == "gap" else re.findall(r"`([^`]+)`", proof) + drives = [r[len("drive.sh "):].strip() for r in refs if r.startswith("drive.sh ")] + tests = [r for r in refs if not r.startswith("drive.sh ")] + # ✓ needs a named test (file:test) or a drive; a bare file asserts nothing. + if label == "✓" and not drives and not any(_pointer(root, r)[1] for r in tests): + problems.append(f"`{node}`: asserted, but its proof names no test or drive") + for scenario in drives: + problem = check_drive(root, scenario) + if problem: + problems.append(f"`{node}`: proof `drive.sh {scenario}`: {problem}") + for ref in tests: + file, name = _pointer(root, ref) + if not file.is_file(): + problems.append(f"`{node}`: proof {ref!r}: no file {file.relative_to(root) if file.is_relative_to(root) else file}") + elif name and name not in file.read_text(errors="replace"): + problems.append(f"`{node}`: proof {ref!r}: {name!r} not found in the file") + return problems + + +# A vendored copy's second line is `# vendored from @; sha256 `, the hash of +# `@` and a newline, then the copy without that line (the canonical file at ), so +# the provenance can't change either. Changes land in , never in a copy. +VENDORED = re.compile(rb"^# vendored from (\S+)@([0-9a-f]{7,40}); sha256 ([0-9a-f]{64})\n", re.M) + + +def vendored_hash(src: bytes, sha: bytes, canonical: bytes) -> str: + return hashlib.sha256(src + b"@" + sha + b"\n" + canonical).hexdigest() + + +def vendored_drift(script: Path) -> str | None: + """Why a vendored copy no longer matches the file it was vendored from, or None.""" + text = script.read_bytes() + m = VENDORED.search(text) + # A pin that no longer parses (CRLF, a mangled hash) is drift too, not an unpinned file. + if not m and re.search(rb"^# vendored from ", text, re.M): + return f"{script.name} has a vendored-from line that doesn't parse: vendor it again" + if not m or vendored_hash(m.group(1), m.group(2), text[:m.start()] + text[m.end():]) == m.group(3).decode(): + return None + src, sha = m.group(1).decode(), m.group(2).decode()[:12] + return (f"{script.name} was edited after it was vendored from {src}@{sha}: make the change in " + f"{src} and vendor it again") + + def changed_files(root: Path, base: str) -> list[str]: # -z: unquoted names. --no-renames: a file moved out of a feature still touches it. out = git(root, "diff", "--name-only", "--no-renames", "-z", f"{base}...HEAD") return [p for p in out.split("\0") if p] +def stale(root: Path, f: Feature) -> tuple[str, list[str]] | None: + """The commit that last changed the feature's file, and its paths changed since then.""" + # The map was last true when its file last changed: a commit can't name its own sha, and a + # squash merge drops the branch's shas, so a recorded `verified:` commit couldn't hold. + rel = f.file.relative_to(root).as_posix() + if git(root, "status", "--porcelain", "--", rel).strip(): + return None # being edited now + last = git(root, "log", "-1", "--format=%h %cs", "--", rel).strip() + if not last: + return None + # Against the working tree, so staged and unstaged code edits count too. + out = git(root, "diff", "--name-only", "--no-renames", "-z", last.split()[0]) + changed = [p for p in out.split("\0") if p and f.touches(p)] + return (last, changed) if changed else None + + def touched(features: list[Feature], files: list[str]) -> tuple[list[Feature], list[str]]: hit = [f for f in features if any(f.touches(p) for p in files)] unmapped = [p for p in files if not any(f.touches(p) for f in features)] @@ -188,6 +410,12 @@ def render(root: Path, hit: list[Feature], unmapped: list[str]) -> str: out.append("Start reading: " + ", ".join(f"`{e}`" for e in f.entry)) if f.tests: out.append("Tests: " + ", ".join(f"`{t}`" for t in f.tests)) + drift = stale(root, f) + if drift: + (last, changed), more = drift, len(drift[1]) - 10 + out.append(f"Possibly stale: {len(changed)} file(s) under its paths changed since its " + f"file last did ({last}): " + ", ".join(changed[:10]) + + (f" and {more} more" if more > 0 else "")) gotchas = f.section("Gotchas") if gotchas: out.append("Gotchas:\n" + gotchas) @@ -215,6 +443,9 @@ def main(argv: list[str] | None = None) -> int: features = load(root / a.map) if a.cmd == "check": problems, notes = check(root, features) + drift = vendored_drift(Path(__file__)) + if drift: + problems.append(drift) for n in notes: print(f"note: {n}") for p in problems: From 9b76f86bf49b62682cf98096d051e9beb0c42bd3 Mon Sep 17 00:00:00 2001 From: vu1n <2105991+vu1n@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:33:39 +0000 Subject: [PATCH 2/3] scripts: re-vendor featuremap.py from vu1n/dev@baa1c6f (drive.sh placeholders are not scenarios) --- scripts/featuremap.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/featuremap.py b/scripts/featuremap.py index 6140963..a2ecfc3 100755 --- a/scripts/featuremap.py +++ b/scripts/featuremap.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# vendored from vu1n/dev@065e973785d468d06812f0fc60c74b09d5915bb3; sha256 9a5862396bfe1a5e9a392d322e7a94cc7ca337c227e806bf9944b34be3343043 +# vendored from vu1n/dev@baa1c6f2b1225bc5644ec045eb0d9dbec9663d4e; sha256 e4a074e6c3ef7cd1e821b9dab914ef01304959beadb7e119f3ad22e5d1e376d1 """A repo's feature map: which code and tests implement each feature, and whether the map's pointers still resolve. @@ -258,8 +258,10 @@ def check_drive(root: Path, scenario: str) -> str | None: text = doc.read_text(errors="replace") if doc.relative_to(root).as_posix() in tracked and doc.is_file() else "" for line in re.findall(r"^\s*Scenarios:(.*)$", text, re.M): known.update(w for quoted in re.findall(r"`([^`]+)`", line) for w in quoted.split()) - # The whole token is the scenario (`send.v2`), less a sentence's closing punctuation. - known.update(t.rstrip(".,;:") for t in re.findall(r"drive\.sh\s+([^\s`'\"|()]+)", text)) + # The whole token is the scenario (`send.v2`), less a sentence's closing punctuation; a + # token with a placeholder (``, `send$v`, `{a,b}`) names none, not even its prefix. + known.update(t.rstrip(".,;:") for t in re.findall(r"drive\.sh\s+([^\s`'\"|()]+)", text) + if not re.search(r"[<>${}]", t)) name = scenario.split()[0] return None if name in known else f"no scenario {name!r} in the verify skill" From bd1ea5039eb13624f901699a4e21e2ff4ee76ecb Mon Sep 17 00:00:00 2001 From: vu1n <2105991+vu1n@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:36:41 +0000 Subject: [PATCH 3/3] scripts: re-vendor featuremap.py from vu1n/dev@fb56315 (adds the stale command) --- scripts/featuremap.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/featuremap.py b/scripts/featuremap.py index a2ecfc3..fddcb3f 100755 --- a/scripts/featuremap.py +++ b/scripts/featuremap.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# vendored from vu1n/dev@baa1c6f2b1225bc5644ec045eb0d9dbec9663d4e; sha256 e4a074e6c3ef7cd1e821b9dab914ef01304959beadb7e119f3ad22e5d1e376d1 +# vendored from vu1n/dev@fb56315bbdb7ccfa7dbe8027b3f8e95a34794d98; sha256 116502ee8f9ab9a1af6a9a2f6e7a7573551bd89db3bd3d9c5604fb289d109376 """A repo's feature map: which code and tests implement each feature, and whether the map's pointers still resolve. @@ -439,6 +439,7 @@ def main(argv: list[str] | None = None) -> int: fe.add_argument("--base", help="diff BASE...HEAD instead of naming files") fe.add_argument("files", nargs="*") sub.add_parser("check", help="exit 1 when a pointer no longer resolves") + sub.add_parser("stale", help="every feature whose paths changed after its file last did") a = ap.parse_args(argv) try: root = Path(git(Path(a.root), "rev-parse", "--show-toplevel").strip()) @@ -454,6 +455,10 @@ def main(argv: list[str] | None = None) -> int: print(f"error: {p}") print(f"{len(features)} features, {len(problems)} broken pointer(s)") return 1 if problems else 0 + if a.cmd == "stale": + hit = [f for f in features if stale(root, f)] + print(render(root, hit, []) if hit else "No feature is possibly stale.\n", end="") + return 0 if not a.files and not a.base: ap.error("features needs FILE ... or --base REF") files = a.files or changed_files(root, a.base)