15fa5273ba
gates / gates (push) Successful in 8s
Check 7 catches "cites a register item and calls it open when it is not" -- the R-168 class, four files, one self-contradicting. Trigger is an openness CLAIM, not any citation: policing every mention would fire on ~30 legitimate provenance citations and the gate would be switched off. Deliberate deviation from the task's literal wording, to keep it alive. Two bugs found by the check's own red-proofs, both of which would have shipped: - the state marker is not self-closing (**SHIPPED - text**), so the first parser read R-168 itself as OPEN -- a gate that cannot convict its founding case is decoration; - the CLOSED exemption was line-wide, so "shipped" in a title pardoned "OPEN R-25b". Check 6 gains WARN-only content classes on MEMORY.md. Link targets are stripped first: the earlier scan reported three expired statements, all three false (dates in filenames), while missing the one real expired claim, whose deadline was written ~08-02 with no ISO date. 39 -> 60 assertions. All four runners green.
605 lines
28 KiB
Python
605 lines
28 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Instruction-file consistency gate — keeps CLAUDE.md files from silently regrowing.
|
|
|
|
Usage: python3 scripts/instructions_gate.py <repo-root> [<repo-root> ...]
|
|
python3 scripts/instructions_gate.py --fast <repo-root> (identical: no network,
|
|
no container runtime)
|
|
|
|
THIS IS A CONSISTENCY GATE, NOT A BUDGET GATE. Measured 2026-08-06 on a 1M-token window: the
|
|
instruction files occupied 15k tokens against 869k free. Space is NOT the constraint here and no
|
|
failure message may claim it is. The line ceiling exists because longer instruction files reduce
|
|
ADHERENCE — Anthropic's guidance is to keep a CLAUDE.md under 200 lines — and because a file nobody
|
|
can hold in their head is where contradictions hide. Four were found in this project on 2026-08-06,
|
|
two of which decided where a destructive drill runs.
|
|
|
|
WHAT IS COUNTED. Every check runs against EFFECTIVE text: block-level HTML comments are stripped
|
|
first, because they are stripped before injection and never reach the model. Verified empirically on
|
|
Claude Code 2.1.222 with a control (two plain markers -> both seen) and a treatment (one marker
|
|
inside <!-- -->, twice -> not seen). That is the whole point of the comment convention: earned
|
|
rationale stays in the repo for human readers at zero cost to the instructions.
|
|
|
|
CHECKS (each names the file and the reason; a missing input is a FAILURE, never a skip):
|
|
|
|
1. <root>/CLAUDE.md is at most MAX_LINES effective lines.
|
|
2. Every <root>/.claude/rules/*.md declares `paths:` in frontmatter, or `unconditional: true`.
|
|
NOTE: `paths:` is a Claude Code feature — a rule carrying it loads only when a file matching
|
|
one of its globs is read. `unconditional: true` is NOT a product feature; it is OUR marker,
|
|
asserting that always-loading was deliberate. The product loads a rule with no `paths:` key
|
|
unconditionally either way, so this check catches the ACCIDENT, not the product behaviour.
|
|
3. No component version literal in effective text. Versions change several times a day and the
|
|
fleet is not uniform, so a version in an instruction file is stale within a day (ask the hub's
|
|
/hosts + /configs, or the box). Historical citations belong in an HTML comment beside the rule
|
|
they justify, where they inform a human and cannot go stale in the model's view.
|
|
IPv4 addresses are NOT versions — a bare \\d+\\.\\d+\\.\\d+ matches the first three octets of
|
|
every one of them, which is how this check would otherwise fail on its own repo.
|
|
4. No TEMPORARY block carrying a date already past. The block this gate was written for said
|
|
"TEMPORARY - until ~2026-08-02 ... Delete this block on return" and was still being read as
|
|
current fact on 2026-08-06, while a sibling repo's CLAUDE.md asserted the opposite.
|
|
5. The workspace-root CLAUDE.md and its versioned copy at
|
|
felhom.eu/documentation/runbooks/workspace-CLAUDE.md are byte-identical. The live file sits in
|
|
a directory that is not a git repo, so the copy is the only version-controlled record of it;
|
|
nothing but this check enforces that they agree.
|
|
6. The auto-memory index (<workspace>/.claude-memory/MEMORY.md) is within its line and byte
|
|
limits, every top-level topic file is referenced by it, and its CONTENT is warned about
|
|
(version literals, host addresses, expired statements, stale-open register citations).
|
|
7. Every R-nnn cited in a CLAUDE.md or a .claude/rules/*.md that is CALLED OPEN really is open.
|
|
|
|
WHY 7 EXISTS (2026-08-06). Four instruction files asserted that CI was still owed, citing R-168,
|
|
four days after it closed — and one of the four contradicted itself, its release section already
|
|
saying R-168 mails the failure. A trim is a VOLUME operation: it does not validate content, and the
|
|
sentence survived because it read as settled. This class, at least, is mechanical.
|
|
|
|
- THE TRIGGER IS AN OPENNESS CLAIM, NOT ANY CITATION. Requiring every mention of a non-open item
|
|
to carry "CLOSED" would fire on ~30 legitimate provenance citations — "(R-161)", "R-117 spike
|
|
§6.3" — which cite an item as the SOURCE OF A FACT, not as outstanding work. A gate that noisy
|
|
is switched off, which is the fate R-29 documented.
|
|
- THE EXEMPTION IS SCOPED TO THE CITATION'S CLAUSE. A line-wide test pardoned a stale claim
|
|
because the words "shipped" appeared elsewhere on the same line, in a title and a filename.
|
|
- A CITATION OF AN ITEM IN NEITHER REGISTER FAILS. A reference to nothing cannot be checked by
|
|
anyone, which is worse than a stale one.
|
|
- THE STATE MARKER IS NOT SELF-CLOSING. Real rows read `**SHIPPED — and the alarm is
|
|
DEMONSTRATED**`. An earlier parser required `**SHIPPED**` and therefore read R-168 — the row
|
|
this check exists for — as open. A gate that cannot convict its own founding case is decoration.
|
|
|
|
WHY 6 EXISTS, AND WHY ITS THREE OUTCOMES DIFFER (2026-08-06).
|
|
|
|
MEMORY.md is the LARGER half of what loads before a word is typed: measured at the workspace root,
|
|
the hand-written root CLAUDE.md was 6.6k tokens and MEMORY.md 8.4k. It is also the one instruction
|
|
file nobody hand-edits — Claude writes it — so nothing was watching it.
|
|
|
|
- OVER THE LIMIT IS A FAILURE. Content past the auto-memory limit is dropped with NO error. A
|
|
silent truncation of the index is the failure mode with no observable at all, which is exactly
|
|
the class this project keeps getting bitten by.
|
|
- AN ORPHAN IS A WARNING, NOT A FAILURE. The store changes between sessions and lives outside git;
|
|
a hard fail would block pushes for something no commit can fix. On 2026-08-06 the index
|
|
referenced 113 files while 157 existed — 44 held knowledge nothing would ever read.
|
|
- AN ABSENT STORE PASSES, AND SAYS SO OUT LOUD. This is a DELIBERATE exception to check 1's
|
|
"a missing input is a FAILURE, never a skip": the store is machine-local by design and a clone
|
|
on any other host legitimately has none. The reason is printed in the tally so an absent store
|
|
can never be mistaken for a silent skip — which is the only thing that made the exception safe
|
|
to grant.
|
|
|
|
THE POSITIVE OBSERVABLE. Every root prints a per-check tally with the measured numbers, not just a
|
|
verdict. "0 failures" alone cannot tell a working gate from a blind one — if the effective line
|
|
count suddenly reads 3, the stripper broke, and the count is where you see it.
|
|
|
|
The kill condition is pinned by scripts/test_instructions_gate.py: an over-length CLAUDE.md still
|
|
FAILS, and a rule file with neither marker still FAILS.
|
|
"""
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
MAX_LINES = 200
|
|
|
|
# The auto-memory index. Limits are the store's own, not this project's taste: content past them is
|
|
# dropped with no error.
|
|
MEMORY_DIRNAME = ".claude-memory"
|
|
MEMORY_INDEX = "MEMORY.md"
|
|
MEMORY_ARCHIVE = "archive"
|
|
MAX_MEMORY_LINES = 200
|
|
MAX_MEMORY_BYTES = 25 * 1024
|
|
|
|
# Block-level HTML comments: stripped before injection, so they cost nothing and are not counted.
|
|
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
|
|
|
# A semver-ish literal that is NOT part of a dotted quad (IPv4) and not part of a longer run.
|
|
VERSION_RE = re.compile(r"(?<![\d.])\d+\.\d+\.\d+(?![\d.])")
|
|
|
|
# "TEMPORARY" anywhere on a line, plus an ISO date somewhere in that block.
|
|
TEMPORARY_RE = re.compile(r"TEMPORARY")
|
|
ISO_DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
|
|
|
|
WORKSPACE_COPY = os.path.join(
|
|
"felhom.eu", "documentation", "runbooks", "workspace-CLAUDE.md"
|
|
)
|
|
|
|
|
|
def effective(text):
|
|
"""The text the model actually receives: HTML comments removed."""
|
|
return COMMENT_RE.sub("", text)
|
|
|
|
|
|
def today_tuple():
|
|
"""Local date as (y, m, d). Injectable via FELHOM_GATE_TODAY for the test suite."""
|
|
override = os.environ.get("FELHOM_GATE_TODAY")
|
|
if override:
|
|
m = ISO_DATE_RE.match(override.strip())
|
|
if m:
|
|
return tuple(int(g) for g in m.groups())
|
|
import datetime
|
|
|
|
d = datetime.date.today()
|
|
return (d.year, d.month, d.day)
|
|
|
|
|
|
def check_length(path, failures, tally):
|
|
with io_open(path) as fh:
|
|
eff = effective(fh.read())
|
|
n = eff.count("\n")
|
|
tally.append(" CLAUDE.md effective lines : %d (ceiling %d)" % (n, MAX_LINES))
|
|
if n > MAX_LINES:
|
|
failures.append(
|
|
"%s: %d effective lines, ceiling %d. This is an ADHERENCE limit, not a space "
|
|
"limit — long instruction files get followed less reliably and hide "
|
|
"contradictions. Move path-bound guidance into .claude/rules/*.md with a `paths:` "
|
|
"list, procedures into the skill that already covers them, and earned rationale "
|
|
"into an HTML comment (free: stripped before injection)."
|
|
% (path, n, MAX_LINES)
|
|
)
|
|
|
|
|
|
def check_rules(root, failures, tally):
|
|
rules_dir = os.path.join(root, ".claude", "rules")
|
|
if not os.path.isdir(rules_dir):
|
|
tally.append(" rule files : none (no .claude/rules/)")
|
|
return
|
|
names = sorted(n for n in os.listdir(rules_dir) if n.endswith(".md"))
|
|
scoped = 0
|
|
for name in names:
|
|
path = os.path.join(rules_dir, name)
|
|
with io_open(path) as fh:
|
|
head = fh.read(2048)
|
|
has_paths = re.search(r"^paths:", head, re.M) is not None
|
|
has_uncond = re.search(r"^unconditional:\s*true\s*$", head, re.M) is not None
|
|
if has_paths:
|
|
scoped += 1
|
|
elif not has_uncond:
|
|
failures.append(
|
|
"%s: rule file has neither a `paths:` frontmatter list nor an explicit "
|
|
"`unconditional: true`. Without `paths:` Claude Code loads it in EVERY session, "
|
|
"which is rarely what a rule file is for — add the globs it applies to, or "
|
|
"declare `unconditional: true` to say the always-loading is deliberate." % path
|
|
)
|
|
tally.append(
|
|
" rule files : %d (%d path-scoped)" % (len(names), scoped)
|
|
)
|
|
|
|
|
|
def check_versions(path, failures, tally):
|
|
with io_open(path) as fh:
|
|
eff = effective(fh.read())
|
|
hits = []
|
|
for i, line in enumerate(eff.split("\n"), 1):
|
|
for m in VERSION_RE.finditer(line):
|
|
hits.append((i, m.group(), line.strip()[:90]))
|
|
tally.append(" version literals : %d" % len(hits))
|
|
for lineno, ver, ctx in hits:
|
|
failures.append(
|
|
"%s:%d: component version literal %r — versions change several times a day and the "
|
|
"fleet is not uniform, so this is stale within a day. Ask the hub (/hosts, /configs) "
|
|
"or the box. A historical citation belongs in an HTML comment beside the rule it "
|
|
"justifies.\n %s" % (path, lineno, ver, ctx)
|
|
)
|
|
|
|
|
|
def check_temporary(path, failures, tally):
|
|
with io_open(path) as fh:
|
|
eff = effective(fh.read())
|
|
lines = eff.split("\n")
|
|
today = today_tuple()
|
|
found = 0
|
|
for i, line in enumerate(lines, 1):
|
|
if not TEMPORARY_RE.search(line):
|
|
continue
|
|
found += 1
|
|
window = "\n".join(lines[i - 1 : i + 6])
|
|
for m in ISO_DATE_RE.finditer(window):
|
|
when = tuple(int(g) for g in m.groups())
|
|
if when < today:
|
|
failures.append(
|
|
"%s:%d: TEMPORARY block carrying the past date %s. A temporary block that "
|
|
"outlives its own deadline is read as current fact — this gate exists "
|
|
"because one did, for four days, while a sibling repo asserted the "
|
|
"opposite. Delete it; the audit trail is the record."
|
|
% (path, i, m.group())
|
|
)
|
|
break
|
|
tally.append(" TEMPORARY blocks : %d" % found)
|
|
|
|
|
|
def check_workspace_copy(workspace_root, failures, tally):
|
|
live = os.path.join(workspace_root, "CLAUDE.md")
|
|
copy = os.path.join(workspace_root, WORKSPACE_COPY)
|
|
if not os.path.exists(live) or not os.path.exists(copy):
|
|
tally.append(" workspace copy : n/a (not this workspace)")
|
|
return
|
|
with io_open(live) as a, io_open(copy) as b:
|
|
same = a.read() == b.read()
|
|
tally.append(" workspace copy identical : %s" % ("yes" if same else "NO"))
|
|
if not same:
|
|
failures.append(
|
|
"%s and %s have diverged. The live workspace file sits in a directory that is not a "
|
|
"git repo, so the copy is its only version-controlled record — nothing but this "
|
|
"check enforces that they agree. Copy the live file over the versioned one."
|
|
% (live, copy)
|
|
)
|
|
|
|
|
|
def memory_referenced(index_text):
|
|
"""Basenames the index points at, via [](x.md) links and [[wikilink]]s alike.
|
|
|
|
Both forms count as a reference. A file reachable only by a wikilink is indexed — it just is not
|
|
a listed entry — and calling it an orphan would push someone to add a duplicate row for it.
|
|
"""
|
|
md = re.findall(r"\]\(([^)]+\.md)\)", index_text)
|
|
wiki = re.findall(r"\[\[([^\]]+)\]\]", index_text)
|
|
names = set(os.path.basename(x) for x in md)
|
|
for w in wiki:
|
|
names.add(os.path.basename(w if w.endswith(".md") else w + ".md"))
|
|
return names
|
|
|
|
|
|
def check_memory(workspace_root, failures, warnings, tally):
|
|
store = os.path.join(workspace_root, MEMORY_DIRNAME)
|
|
if not os.path.isdir(store):
|
|
# DELIBERATE exception to "a missing input is a FAILURE" — see the module docstring. The
|
|
# reason is printed so this can never read as a silent skip.
|
|
tally.append(
|
|
" memory index : absent (%s/ not on this host — the auto-memory store is "
|
|
"machine-local by design, so a clone elsewhere legitimately has none; PASS with reason)"
|
|
% MEMORY_DIRNAME
|
|
)
|
|
return
|
|
|
|
index = os.path.join(store, MEMORY_INDEX)
|
|
if not os.path.exists(index):
|
|
failures.append(
|
|
"%s: the memory store exists but has no %s. An un-indexed store is unreachable "
|
|
"knowledge — every topic file is read on demand, and the index is the only thing that "
|
|
"names them." % (store, MEMORY_INDEX)
|
|
)
|
|
tally.append(" memory index : MISSING")
|
|
return
|
|
|
|
with io_open(index) as fh:
|
|
text = fh.read()
|
|
nlines = text.count("\n")
|
|
nbytes = len(text.encode("utf-8"))
|
|
tally.append(
|
|
" memory index : %d lines (ceiling %d), %d bytes (ceiling %d)"
|
|
% (nlines, MAX_MEMORY_LINES, nbytes, MAX_MEMORY_BYTES)
|
|
)
|
|
|
|
if nlines > MAX_MEMORY_LINES:
|
|
failures.append(
|
|
"%s: %d lines, ceiling %d. Content past the auto-memory limit is DROPPED WITH NO "
|
|
"ERROR — a truncated index is a silent failure with no observable. Move detail out of "
|
|
"the index into the topic files it points at; the index is what loads, the topic files "
|
|
"are read on demand." % (index, nlines, MAX_MEMORY_LINES)
|
|
)
|
|
if nbytes > MAX_MEMORY_BYTES:
|
|
failures.append(
|
|
"%s: %d bytes, ceiling %d. Same reason as the line ceiling — silent truncation. Move "
|
|
"detail into topic files rather than dropping entries."
|
|
% (index, nbytes, MAX_MEMORY_BYTES)
|
|
)
|
|
|
|
# --- content classes, as WARNINGS (see memory_content_warnings for why not failures) ---
|
|
cw = memory_content_warnings(text, today_tuple())
|
|
tally.append(
|
|
" memory index content : %d version literal(s), %d host address(es), %d expired "
|
|
"statement(s) [WARN only]"
|
|
% (len(cw["versions"]), len(cw["addresses"]), len(cw["expired"]))
|
|
)
|
|
# Fourth class, beyond the three the CLAUDE.md files are failed on: a stale-open register
|
|
# citation. Added because TWO of the three false statements found in this file on 2026-08-06
|
|
# were exactly this ("R-193 decision open", "OPEN R-25b" — both closed), and both were found by
|
|
# hand. Check 7 polices the hand-written files for it; nothing policed this one.
|
|
reg = register_state(workspace_root)
|
|
if reg:
|
|
stale_cites = []
|
|
for lineno, raw in enumerate(text.split("\n"), 1):
|
|
for item in sorted(set(CITE_RE.findall(raw))):
|
|
cl = clause_around(raw, item)
|
|
if not (OPENNESS_RE.search(cl) and not SAYS_CLOSED_RE.search(cl)):
|
|
continue
|
|
if reg.get(item, "missing") != "open":
|
|
stale_cites.append((lineno, item, raw.strip()[:90]))
|
|
if stale_cites:
|
|
tally.append(" memory stale citations : %d [WARN only]" % len(stale_cites))
|
|
for lineno, item, ctx in stale_cites[:6]:
|
|
warnings.append(
|
|
"%s:%d: calls %s open, but the register does not.\n %s"
|
|
% (index, lineno, item, ctx)
|
|
)
|
|
|
|
for kind, label, why in (
|
|
("expired", "EXPIRED statement", "it is read as current fact"),
|
|
("versions", "version literal", "the fleet is not uniform, so it is stale within a day"),
|
|
("addresses", "host address", "operations/nodes.md is the single home for these"),
|
|
):
|
|
for lineno, hit, ctx in cw[kind][:6]:
|
|
warnings.append(
|
|
"%s:%d: %s %r — %s. Not a failure: Claude writes this file between sessions, so "
|
|
"this warning is aimed at the model that will next edit it, not at whoever is "
|
|
"pushing.\n %s" % (index, lineno, label, hit, why, ctx)
|
|
)
|
|
if len(cw[kind]) > 6:
|
|
warnings.append(
|
|
"%s: ... and %d more %s(s) — full list from the tally counts above."
|
|
% (index, len(cw[kind]) - 6, label)
|
|
)
|
|
|
|
referenced = memory_referenced(text)
|
|
on_disk = set(
|
|
n for n in os.listdir(store)
|
|
if n.endswith(".md") and n != MEMORY_INDEX
|
|
and os.path.isfile(os.path.join(store, n))
|
|
)
|
|
orphans = sorted(on_disk - referenced)
|
|
archived = 0
|
|
arc = os.path.join(store, MEMORY_ARCHIVE)
|
|
if os.path.isdir(arc):
|
|
archived = len([n for n in os.listdir(arc) if n.endswith(".md")])
|
|
tally.append(
|
|
" memory topic files : %d indexed, %d orphaned, %d archived"
|
|
% (len(on_disk) - len(orphans), len(orphans), archived)
|
|
)
|
|
if orphans:
|
|
# WARN, never FAIL: the store is outside git and changes between sessions, so a failure
|
|
# here would block pushes for something no commit can fix.
|
|
warnings.append(
|
|
"%s: %d top-level topic file(s) are not referenced by %s, so nothing will ever read "
|
|
"them: %s%s"
|
|
% (
|
|
store,
|
|
len(orphans),
|
|
MEMORY_INDEX,
|
|
", ".join(orphans[:8]),
|
|
(" (+%d more)" % (len(orphans) - 8)) if len(orphans) > 8 else "",
|
|
)
|
|
)
|
|
|
|
|
|
# A date inside a markdown link TARGET is a filename, not a claim: `[x](vacation-...-2026-07-20.md)`.
|
|
# Matching those produced THREE false "expired statement" findings on 2026-08-06 — every one of the
|
|
# three reported that day — while missing the one real expired claim in the same file, whose deadline
|
|
# was written `~08-02` and carried no ISO date at all. A scan that reports the wrong three and misses
|
|
# the right one is worse than no scan.
|
|
LINK_TARGET_RE = re.compile(r"\]\([^)]*\)")
|
|
# Deadline language, including the bare MM-DD form the real one used.
|
|
DEADLINE_RE = re.compile(
|
|
r"\b(till|until|by)\s+~?\s*(\d{4}-\d{2}-\d{2}|\d{2}-\d{2})\b", re.I
|
|
)
|
|
LOOPBACK = ("127.0.0.1", "0.0.0.0", "255.255.255.255")
|
|
IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
|
|
|
|
|
def memory_content_warnings(text, today):
|
|
"""The three content classes the CLAUDE.md files are FAILED on, applied to the index as WARNINGS.
|
|
|
|
WARN and not FAIL, deliberately: Claude writes this file between sessions, so a hard failure
|
|
would refuse a human's push over a line no human typed. The warning IS the mechanism — it is read
|
|
by the same model that writes the file, which makes the loop self-correcting rather than a chore
|
|
for a person.
|
|
"""
|
|
out = {"versions": [], "addresses": [], "expired": []}
|
|
for i, raw in enumerate(text.split("\n"), 1):
|
|
# strip link targets before ANY content judgement — a filename is not an assertion
|
|
line = LINK_TARGET_RE.sub("]()", raw)
|
|
|
|
for m in VERSION_RE.finditer(line):
|
|
out["versions"].append((i, m.group(), raw.strip()[:90]))
|
|
for m in IPV4_RE.finditer(line):
|
|
if m.group() not in LOOPBACK:
|
|
out["addresses"].append((i, m.group(), raw.strip()[:90]))
|
|
for m in DEADLINE_RE.finditer(line):
|
|
token = m.group(2)
|
|
if len(token) == 5: # MM-DD — assume the current year, which is how it was written
|
|
when = (today[0], int(token[:2]), int(token[3:]))
|
|
else:
|
|
when = tuple(int(x) for x in token.split("-"))
|
|
if when < today:
|
|
out["expired"].append((i, m.group(0), raw.strip()[:90]))
|
|
return out
|
|
|
|
|
|
def register_state(workspace_root):
|
|
"""Map R-nnn -> 'open' | 'closed' | 'historical' | 'missing'.
|
|
|
|
OPEN-ITEMS.md is the authority on what is OPEN and says so in its own header. An item is open
|
|
when it OWNS A ROW there whose State cell is not a closed marker. An item with no row of its own
|
|
is not open work — it may still be mentioned inside another row, and it is usually in ROADMAP.md,
|
|
which keeps the full history. Anything in neither file is a reference to nothing.
|
|
"""
|
|
base = os.path.join(workspace_root, "felhom.eu", "documentation", "backlog")
|
|
reg = os.path.join(base, "OPEN-ITEMS.md")
|
|
hist = os.path.join(base, "ROADMAP.md")
|
|
if not os.path.exists(reg):
|
|
return None
|
|
|
|
with io_open(reg) as fh:
|
|
reg_text = fh.read()
|
|
hist_text = ""
|
|
if os.path.exists(hist):
|
|
with io_open(hist) as fh:
|
|
hist_text = fh.read()
|
|
|
|
state = {}
|
|
for line in reg_text.split("\n"):
|
|
m = re.match(r"\|\s*\*\*(R-\d+[a-z]?)\*\*\s*\|", line)
|
|
if not m:
|
|
continue
|
|
cells = [c.strip() for c in line.rstrip().strip("|").split("|")]
|
|
# The State column is index 2 in BOTH table shapes here (`ID|What|State` and
|
|
# `ID|What|State|Blocked on|Next action|Owner`). Scan from there rather than taking the
|
|
# last cell: a row whose prose contains a `|` splits into extra cells and would otherwise
|
|
# be read from the wrong one.
|
|
#
|
|
# THE MARKER IS NOT SELF-CLOSING. Real rows read `**SHIPPED — and the alarm is
|
|
# DEMONSTRATED**`, not `**SHIPPED**`. An earlier version of this parser required the
|
|
# closing `**` immediately after the word and therefore read R-168 — the row this whole
|
|
# check exists for — as OPEN. A gate that cannot convict its own founding case is decoration.
|
|
state[m.group(1)] = "open"
|
|
for cell in cells[2:]:
|
|
if not cell.startswith("**"):
|
|
continue
|
|
head = cell[: cell.find("**", 2) + 2] if cell.find("**", 2) > 0 else cell
|
|
if re.search(r"\b(CLOSED|SHIPPED|DONE|COMPLETE)\b", head) and not re.search(
|
|
r"\bopen\b", head, re.I
|
|
):
|
|
state[m.group(1)] = "closed"
|
|
break
|
|
|
|
mentioned = set(re.findall(r"\bR-\d+[a-z]?\b", reg_text)) | set(
|
|
re.findall(r"\bR-\d+[a-z]?\b", hist_text)
|
|
)
|
|
for item in mentioned:
|
|
state.setdefault(item, "historical")
|
|
return state
|
|
|
|
|
|
# Language that CLAIMS an item is still outstanding. The check fires on these, not on every
|
|
# citation — see check 7's rationale in the module docstring.
|
|
OPENNESS_RE = re.compile(
|
|
r"\b(still owed|is owed|are owed|still open|stays open|remains open|still pending|"
|
|
r"pending\b|still needed|not yet|awaiting|awaits|outstanding|unresolved|to be done|"
|
|
r"OPEN\s+R-\d)", re.I
|
|
)
|
|
SAYS_CLOSED_RE = re.compile(r"\b(CLOSED|DONE|SHIPPED|RESOLVED|COMPLETE)\b", re.I)
|
|
CITE_RE = re.compile(r"\bR-\d+[a-z]?\b")
|
|
# Clause boundaries. The exemption is scoped to the clause holding the citation, NOT the whole line:
|
|
# `- [Customer RESET shipped](…) — …; OPEN R-25b` contains "shipped" twice, in a title and a
|
|
# filename, and a line-wide exemption pardoned the stale claim at the end of it. Found by this
|
|
# check's own red-proof, which failed to go red.
|
|
CLAUSE_SPLIT_RE = re.compile(r"[;.]\s+|\s+—\s+|\s+\|\s+")
|
|
|
|
|
|
def clause_around(line, item):
|
|
"""The clause of `line` containing `item` — what "the same sentence" means for the exemption."""
|
|
for part in CLAUSE_SPLIT_RE.split(line):
|
|
if re.search(r"\b%s\b" % re.escape(item), part):
|
|
return part
|
|
return line
|
|
|
|
|
|
def check_citations(root, workspace_root, failures, warnings, tally):
|
|
"""Check 7 — a citation that calls a register item OPEN must be right.
|
|
|
|
Scope: <root>/CLAUDE.md and <root>/.claude/rules/*.md. Effective text only, so a citation
|
|
parked in an HTML comment for history is not policed.
|
|
"""
|
|
state = register_state(workspace_root)
|
|
if state is None:
|
|
tally.append(" register citations : n/a (no OPEN-ITEMS.md — not this workspace)")
|
|
return
|
|
|
|
targets = []
|
|
claude_md = os.path.join(root, "CLAUDE.md")
|
|
if os.path.exists(claude_md):
|
|
targets.append(claude_md)
|
|
rules_dir = os.path.join(root, ".claude", "rules")
|
|
if os.path.isdir(rules_dir):
|
|
targets += [
|
|
os.path.join(rules_dir, n) for n in sorted(os.listdir(rules_dir)) if n.endswith(".md")
|
|
]
|
|
|
|
cited = 0
|
|
for path in targets:
|
|
with io_open(path) as fh:
|
|
eff = effective(fh.read())
|
|
for lineno, line in enumerate(eff.split("\n"), 1):
|
|
items = CITE_RE.findall(line)
|
|
if not items:
|
|
continue
|
|
cited += len(items)
|
|
for item in sorted(set(items)):
|
|
cl = clause_around(line, item)
|
|
claims_open = bool(OPENNESS_RE.search(cl)) and not SAYS_CLOSED_RE.search(cl)
|
|
st = state.get(item, "missing")
|
|
if st == "missing":
|
|
failures.append(
|
|
"%s:%d: cites %s, which appears in NEITHER OPEN-ITEMS.md nor ROADMAP.md. "
|
|
"A reference to nothing is worse than a stale one — it cannot be checked "
|
|
"by anyone.\n %s" % (path, lineno, item, line.strip()[:110])
|
|
)
|
|
elif claims_open and st != "open":
|
|
failures.append(
|
|
"%s:%d: claims %s is still open, but the register says it is %s. Four "
|
|
"instruction files asserted CI was still owed (R-168) four days after it "
|
|
"closed, and one contradicted itself about it — a trim is a volume "
|
|
"operation and does not validate content. Either drop the claim or say "
|
|
"CLOSED in the same sentence.\n %s"
|
|
% (path, lineno, item, "not an open row" if st == "historical" else st,
|
|
line.strip()[:110])
|
|
)
|
|
tally.append(" register citations : %d cited, %d register items known" % (cited, len(state)))
|
|
|
|
|
|
def io_open(path):
|
|
return open(path, "r", encoding="utf-8")
|
|
|
|
|
|
def main(argv):
|
|
roots = [a for a in argv[1:] if a != "--fast"]
|
|
if not roots:
|
|
sys.stderr.write("usage: instructions_gate.py [--fast] <repo-root> ...\n")
|
|
return 2
|
|
|
|
failures = []
|
|
warnings = []
|
|
for root in roots:
|
|
root = os.path.abspath(root)
|
|
print("instructions_gate: %s" % root)
|
|
tally = []
|
|
|
|
claude_md = os.path.join(root, "CLAUDE.md")
|
|
if not os.path.exists(claude_md):
|
|
failures.append(
|
|
"%s: no CLAUDE.md. A missing input is a FAILURE, never a skip — a gate that "
|
|
"silently passes on an absent file is how the check stops running." % root
|
|
)
|
|
else:
|
|
check_length(claude_md, failures, tally)
|
|
check_versions(claude_md, failures, tally)
|
|
check_temporary(claude_md, failures, tally)
|
|
|
|
check_rules(root, failures, tally)
|
|
check_workspace_copy(os.path.dirname(root), failures, tally)
|
|
check_memory(os.path.dirname(root), failures, warnings, tally)
|
|
check_citations(root, os.path.dirname(root), failures, warnings, tally)
|
|
|
|
for line in tally:
|
|
print(line)
|
|
|
|
if warnings:
|
|
print("")
|
|
for w in warnings:
|
|
print("WARNING: %s" % w)
|
|
|
|
if failures:
|
|
print("")
|
|
print("instructions_gate: %d FAILURE(S)" % len(failures))
|
|
for f in failures:
|
|
print(" - %s" % f)
|
|
return 1
|
|
|
|
print("")
|
|
print("instructions_gate: OK%s" % (" (%d warning(s))" % len(warnings) if warnings else ""))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|