Files
ironclaw/scripts/ci/test-check-guidance.py
Benjamin Kurrek 8b32989d0a Guidance unification: one canonical home per fact, a measured loader story, and a gate that keeps it true (#7306)
* ci(guidance): add check-guidance.py — guidance must reference the tree that exists

Four mechanical drift classes become build failures: every repo path named
by agent guidance (root AGENTS.md/CLAUDE.md, crates/** AGENTS/CLAUDE/
CONTRACT/README, .claude/rules/*.md, .claude/skills/*/SKILL.md) must
resolve in the tracked tree; every rules/skills frontmatter paths: glob
must match at least one tracked file (the dead-trigger class that let
skills.md never fire); every crate directory appears in its family's
AGENTS.md crate table (the guidance half of check-target-tree.py); and
every crate has a README.md (measured 62/62, so it gates).

Extraction is designed against false positives: fenced blocks, placeholder
tokens, MCP method names, dated-correction (✎) lines, and
'check-guidance: path-ok' lines are not claims; resolution honors the
citation forms measured on the live tree (root-relative, doc-relative,
name-prefix, crate-qualified-by-context, module-relative within the citing
crate). KNOWN_MISSING is a shrink-only suppression table — a row whose
reference stops dangling fails the gate until deleted, and surviving rows
print as warnings every run.

Fails closed on unreadable files, unparseable frontmatter, broken crate
discovery, and near-empty scans (floor constants). Self-test in
test-check-guidance.py (23 cases, refusals first, real repository last),
wired beside check-target-tree.py in code_style.yml; the test planner
classifies all three paths as static-control (verified exit 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(guidance): consolidate crate-tier CLAUDE.md files; rename module specs to CONTRACT.md

Steps 2+3 of the guidance unification (docs/reborn/guidance-conventions.md):

- Rename the four Module Specs table specs CLAUDE.md -> CONTRACT.md (llm,
  filesystem, webui, composition), matching the identity/trust precedent.
  Charter gates repointed (llm module_charter, webui handlers_module_charter)
  and every live reference updated; pointer stubs left behind so tooling that
  loads CLAUDE.md still lands on the spec.
- Fold the nine substantive out-of-table CLAUDE.md files: wasm, mcp, sandbox,
  auth, assistant, trace_commons, extension_manager become AGENTS.md-canonical
  (gates repointed with pinned phrases kept verbatim: the wasm_sandbox_core
  arch pin, mcp module_charter, auth module_charter, assistant
  reborn_services_module_charter); network and secrets fold into their README
  Invariants sections and drop the crate guidance pair entirely.
- Mark with the convention's absence-claim annotation the five crate-tier
  lines grandfathered by check-guidance KNOWN_MISSING (llm CONTRACT.md x3,
  composition CONTRACT.md, hooks AGENTS.md) and mark trace_commons'
  prescribed tests/queue.rs mirror as prescriptive-future.
- tests/CLAUDE.md: replace the retired root Current-Limitations citation with
  the measured ironclaw_observability description.

End state: zero prose CLAUDE.md outside the Module Specs table at the crate
tier (the four ironclaw_agent_loop src/tests directory guides stay, same
footing as the tests-tree harness guides).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(guidance): unify the root pair — AGENTS.md canonical, CLAUDE.md adapter

Step 1: root AGENTS.md (198 lines) and root CLAUDE.md (286 lines) shared zero
identical lines — the forked-pair drift the guidance convention forbids at
crate level, live at the root. Root AGENTS.md is now the canonical
tool-neutral contract (build/run/debug commands, hard invariants including
the unified extension model and the credential_name/extension_name identity
rules, the Module Specs table — now uniformly CONTRACT.md and gaining the
existing ironclaw_trust/CONTRACT.md row — testing discipline, tree map,
discovery, change discipline; 152 lines). Root CLAUDE.md is an @AGENTS.md
adapter plus the genuinely Claude-specific tail: skills/rules index,
codebase-graph MCP recipes, and the REPL info!/warn! logging rule (51 lines).

Cut while merging, each measured against the tree: the v1 Job State Machine
(no such state machine exists under crates/), Current Limitations (stale —
the observability claim no longer matches the crate), the Skills System
section (.claude/rules/skills.md and the domain crate own it), Extracted
Crates, the re-derivable key-traits list, and the long channel-onboarding
narrative (now three lines pointing at crates/extensions/AGENTS.md and the
worked slack example).

Every live citation of the root pair's moved sections is repointed (crates/
routing map + README, the deslop-reborn command, types/type-placement rules,
skills/common/config crate docs, a loop_host doc comment). The git-ignored
.codebase-memory/artifact.json mention carries the absence-claim annotation
for the check-guidance KNOWN_MISSING handoff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(guidance): make CLAUDE.md a symlink to AGENTS.md at every tier

The loader question is now measured, not assumed. Headless canary experiment
with a discriminating control: a symlinked nested CLAUDE.md's target content IS
injected when a file in that directory is read, an @AGENTS.md import inside a
nested CLAUDE.md also expands, and a nested AGENTS.md alone is NOT read. So one
uniform rule holds everywhere: wherever an AGENTS.md exists, CLAUDE.md sits
beside it as a symlink — same bytes, zero maintenance, no second document to
drift.

64 pointer stubs become symlinks. The four spec crates keep CONTRACT.md as
canonical; their AGENTS.md routes there, so the spec stays one hop away while
the working rules now auto-inject instead of costing a voluntary read.

Also reconciled check-guidance.py's shrink-only KNOWN_MISSING table: all 8 rows
deleted because the content pass fixed the underlying lines, and the three
absence-claims the gate then surfaced carry markers. The table is empty.

Caveat recorded for the convention: nested injection fires only below cwd, and
appears not to fire in subagent sessions — family docs must stand alone when
read deliberately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(guidance): amend the convention with measured loader mechanics and budgets

The first version made crate AGENTS.md canonical and CLAUDE.md a pointer, which
moved working rules out of Claude Code's auto-inject path. Records what was
measured instead: subtree CLAUDE.md injects lazily, symlinks and @imports both
carry content, nested AGENTS.md is not read natively, and injection does not
fire in subagent sessions — so every doc must stand alone when read deliberately.

Adds size budgets per tier, extends scope to .claude/rules and .claude/skills
(where the worst drift was), names check-guidance.py as the enforcement with its
suppression markers, warns that some guidance is test-parsed (including the
heading-shadowing trap), and adds the remove/rename checklist that mirrors add.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(guidance): composition keeps a real CLAUDE.md, not a symlink

The blanket symlink pass broke composition_root_embeds_no_prompt_content, and
the gate is right to refuse: its ownership walks do not follow symlinks, so
stepping over one would let it report clean on a subtree it never read. This
crate keeps a regular pointer file, with the reason written in the file so the
next person does not 'fix' the inconsistency back into a break.

The uniform alias rule now has two stated exceptions: the root (real file, it
carries a Claude-only tail) and composition (real file, this gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(guidance): enforce the CLAUDE.md alias rule; scope path-ok to the marked reference

The branch's central invariant — a `CLAUDE.md -> AGENTS.md` symlink beside
every AGENTS.md at the root and under crates/ — was unguarded: the audit
proved a committed symlink deletion left the gate green (a working-tree
deletion only tripped the accidental "cannot read guidance file" refusal).
Check 5 now judges the git index (`git ls-files -s` + `cat-file`): the
alias must be tracked, mode 120000, targeting exactly `AGENTS.md`. The two
real-file exceptions are named rows with reasons (the root adapter's
Claude-only tail; composition's symlink-refusing ownership walks), and a
row that stops matching the tree fails the gate rather than lingering.
Sabotage-verified on the real tree: `git rm --cached` on an alias went red
naming the pair; converting one to a tracked regular file went red;
restore went green (65 aliases verified).

Also from the audit:

- A `path-ok` marker now vouches for the one reference immediately
  preceding it instead of exempting its whole line — the audit slipped a
  fresh dangling path onto a marked line and passed. The `✎` glyph stays
  line-scoped by documented design. Both in-tree marker usages already
  sit marker-after-reference and keep working.
- Document the structural blind spot: a dead reference whose first
  segment died with its whole tree (the v1 `src/…` monolith) reads as
  historical narration and cannot be flagged; only review catches it.
- Re-measure the fail-closed floor comment — the shipped one claimed
  174 guidance files / ~800 references / 30 globs against a tree that
  measures 237 / ~2070 / 38 — and add a floor for alias-site discovery.

Self-test grows six cases: index-deleted alias, regular-file alias,
wrong-target alias, the load-bearing root exception row, exception rows
matching reality, and the marker-narrowing exploit. The `--tracked-files`
override marks symlinks as `<path> -> <target>`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(guidance): repoint dead skill refs, record alias carve-outs, honest size budgets

Content half of the guidance-unification audit fixes:

- architecture-video SKILL.md told readers to read `src/tools/README.md`
  and `src/workspace/README.md` — the v1 monolith is gone
  (`git ls-files | grep -c '^src/'` is 0) and the gate structurally
  cannot flag first-segment-dead paths. Repointed at the Reborn
  successors: `crates/extensions/AGENTS.md` and
  `crates/domains/ironclaw_memory/README.md`.
- guidance-conventions.md now records what only commit messages knew:
  the composition real-file exception beside the root one; the four
  sanctioned ironclaw_agent_loop sub-module CLAUDE.md guides; and the
  alias rule's actual scope (root + crates/**), naming the two
  out-of-scope AGENTS.md (docs/reborn/contracts, ironclaw_silk_decoder)
  instead of a "wherever" wording the tree contradicted.
- Size budgets re-derived from measurement (family <=220, crate <=160)
  with the four crate-tier exceptions named and reasoned. The shipped
  <=150/<=80 numbers were exceeded by 3 family and 24 of 54 crate docs
  on day one, which made the budget unreadable as a signal. No document
  was padded or truncated to fit.
- Root CLAUDE.md used the dated-correction glyph on the deliberately
  untracked `.codebase-memory/artifact.json` reference — suppression
  duty outside the glyph's documented historical-prose meaning. Swapped
  for `<!-- check-guidance: path-ok -->` beside the reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review(7306): CodeRabbit triage — guidance gate runs for the files it governs, brace globs, honest floors, identity-column family tables, doc-truth fixes

Trigger (Major, the inert-guard finding): fast-checks was gated on has_code,
whose regex covers none of .claude/, the root AGENTS.md/CLAUDE.md pair, or
docs/ — so a PR editing only a rule's paths: trigger skipped the gate built
for exactly that change. New has_guidance output OR-s those surfaces into
fast-checks only (clippy/JS lanes stay code-scoped); has_code keeps its
pinned meaning. Pinned by a ws12_workflow_contracts.py row and verified by
replaying representative change lists through the workflow's own extracted
EREs.

check-guidance.py: glob_to_regex now translates {a,b} brace alternation
(nested; unmatched braces stay literal) so a legitimate crates/**/*.{rs,toml}
trigger counts as live instead of being reported dead; MIN_RULE_GLOBS 1->20
and MIN_ALIAS_PAIRS 10->40 (~half of measured 38/65, so a degraded parser
refuses instead of passing); family-table coverage now requires the crate in
a row's identity (first) column — an incidental mention in another row's
prose no longer counts (measured 0 regressions on the live tree). Self-tests:
+3 (brace trigger end-to-end, duplicate KNOWN_MISSING rows, identity-column
regression) and the real-repository case documents its deliberate git
coupling. Floors sabotage-verified.

Doc truth, measured against code: composition CONTRACT — WS stream shares
SseCapacity (stream_events_ws try_acquire, pinned test) replacing 'No WS
surface to bound', webui_v2_app returns Result<Router, WebuiServeError>;
llm CONTRACT — the circuit breaker wraps failover (apply_decorator_chain
order), not the reverse; filesystem CONTRACT — dependency rule now names the
real manifest set (+libsql_runtime, +observability); extension_manager
AGENTS — the loops layer flip landed (layer = "loops"); four stale 'has no
CLAUDE.md' claims updated for the new symlink aliases (config, common,
event_store x2); root AGENTS — clippy line gains -- -D warnings (CI denies
warnings; unflagged clippy exits 0 with them) and the error bullet routes to
.claude/rules/error-handling.md; assistant/webui validation sections document
the real lane structure (self-dev-dep unifies test-support on, so the missing
shape is the no-dev-deps production lane, the #7119 class).

Stale pre-family paths in .rs prose: 594 crates/ironclaw_* citations
measured; 130 sit in comments, of which 106 repointed to their family homes
(every rewritten path verified to resolve), 10 of those needed deeper
repoints (files that moved crates: capability_host.rs, channel_pairing.rs,
approval_store_contract.rs, secret_store.rs, loop_contracts
instruction_bundle.rs, assistant communication_context.rs, loop_host
surface_disclosure.rs, resolver_tests.rs), 24 left deliberately (flat-
spelling narration about the family move itself, deleted-crate history,
synthetic fixture names, and two #6945-class pointers whose target is gone
at every spelling). 464 string-literal citations left: the specificity
test resolves legacy spellings through the crate inventory by design.

Triage of PR #7306 review comments; no gate weakened, both alias
exceptions preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review(7306): drop the one comment repoint in tests/e2e_trace_runtime_policy_org_ceiling_yolo.rs

reborn_pr_test_plan.py has no mapping for this root test (it matches
neither the tests/reborn_* partition inventory nor any other arm), so ANY
PR touching it fails 'Detect Reborn test scope' — a pre-existing planner
gap, confirmed against origin/main with a one-file changed list. The stale
crates/ironclaw_runtime_policy comment path inside it stays until the
planner learns the file; noted for follow-up rather than smuggling planner
surgery into a review-triage branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(contributing): stop annotating the loose iteration clippy line as 'zero warnings'

Same class as the root AGENTS.md fix: unflagged clippy exits 0 with
warnings, so the annotation overclaimed. CONTRIBUTING's two-tier design
(loose iteration block, then a stricter pre-PR block that already carries
-- -D warnings) is deliberate and stays; only the claim is aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: recount the frozen WebUI route table after the #7306 merge — 93 -> 97

#7236 (main) added the four operator inspector routes without bumping the
stated counts; re-derived on the merged tree:
rg -c 'pub const WEBUI_V2_ROUTE_' crates/product/ironclaw_webui/src/webui_v2/descriptors.rs -> 97.
Updates the two live claims (webui README, PROPOSAL SS6.9.4 with its
strike-through recount convention); historical/superseded 92-row mentions
stay as written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): the code-style roll-up must judge fast-checks for guidance-only PRs

The has_guidance trigger made fast-checks RUN for .claude/ and root-pair
changes, but the roll-up's has_code==false branch exits 0 before it ever reads
fast-checks' result — so check-guidance.py could fail and Code Style would
still report success. The gate ran and could never block: exactly the
inert-guard shape this change exists to remove, reintroduced one layer up.

Fixed the way main's docs-publication gate already does it — judged before the
early exit, with the reason in a comment. Sabotage-verified: has_guidance=true
plus fast-checks=failure now exits 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:18:15 +00:00

603 lines
27 KiB
Python

#!/usr/bin/env python3
"""Self-test for `scripts/ci/check-guidance.py`.
A guidance gate that cannot fail is worse than no guidance gate: it converts
"nobody checked the docs" into "something checked them and they were fine".
So almost every case here is about the gate REFUSING — a dangling backticked
reference, a dead markdown link, a `paths:` glob that matches nothing (the
`.claude/rules/skills.md` failure this gate exists for), a crate missing from
its family table, a crate without a README, a family without an AGENTS.md, a
`CLAUDE.md` alias that is deleted from the index / a regular file / a symlink
to the wrong target (the 2026-08-06 audit's committed-deletion exploit), an
alias-exception row that stopped matching the tree, a `path-ok` marker being
read as covering its whole line, a suppression row that outlived its debt,
frontmatter the parser cannot trust, an unreadable guidance file, and
extraction floors that stop a broken scanner from reporting an empty pass as
clean. The legitimate-citation forms the extractor must NOT flag get their
own case, and the happy path runs against the real repository, last.
The gate reads the tracked set through `git ls-files -s`; these tests feed it
a written file instead (`--tracked-files`, where `<path> -> <target>` marks a
symlink), so a doctored tree costs a text edit rather than a fixture git
repository. Fixture runs patch the floor constants, empty the real
`KNOWN_MISSING` table, and shrink `ALIAS_REAL_FILE_EXCEPTIONS` to the root
row the fixture needs; the real-repository run patches nothing.
"""
from __future__ import annotations
import contextlib
import importlib.util
import io
import os
import pathlib
import sys
import tempfile
import unittest
from unittest import mock
SCRIPT = pathlib.Path(__file__).with_name("check-guidance.py")
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
_SPEC = importlib.util.spec_from_file_location("guidance_check", SCRIPT)
assert _SPEC and _SPEC.loader
GATE = importlib.util.module_from_spec(_SPEC)
# Registered before execution: `@dataclasses.dataclass` resolves a class's
# module through `sys.modules`, and on 3.12+ a hyphenated script loaded by path
# alone raises there instead of defining the dataclass.
sys.modules[_SPEC.name] = GATE
_SPEC.loader.exec_module(GATE)
# The same module instance the gate imported — patches on it are visible to
# the gate, and its inventory cache must be dropped around every fixture.
crate_tree = sys.modules["crate_tree"]
class GuidanceGateTests(unittest.TestCase):
# -- fixture ----------------------------------------------------------
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.root = pathlib.Path(self._tmp.name)
crate_tree.reset_inventory_cache()
self.addCleanup(self._tmp.cleanup)
self.addCleanup(crate_tree.reset_inventory_cache)
def write(self, relative: str, content: str) -> None:
path = self.root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def symlink(self, relative: str, target: str) -> None:
path = self.root / relative
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_symlink() or path.exists():
path.unlink()
os.symlink(target, path)
def build_fixture(self) -> None:
"""A minimal, internally consistent guidance tree that passes.
The root `CLAUDE.md` is a real file (covered by the root exception row
`run_gate` patches in); the family `AGENTS.md` gets the
`CLAUDE.md -> AGENTS.md` symlink alias the alias rule requires.
"""
self.write("AGENTS.md", "Start at [the family](crates/core/AGENTS.md).\n")
self.write("CLAUDE.md", "Read `docs/guide.md` and `crates/core/ironclaw_alpha/README.md`.\n")
self.symlink("crates/core/CLAUDE.md", "AGENTS.md")
self.write("docs/guide.md", "not scanned, only referenced\n")
self.write(
".claude/rules/alpha.md",
'---\npaths:\n - "crates/**/*.rs"\n---\n# Alpha rule\n',
)
self.write(
".claude/skills/demo/SKILL.md",
"---\nname: demo\ndescription: none\n---\nSee `crates/core/AGENTS.md`.\n",
)
self.write(
"crates/core/AGENTS.md",
"# core family\n\n"
"| Crate | Charter |\n| --- | --- |\n"
"| [`ironclaw_alpha`](./ironclaw_alpha) | records |\n"
"| [`beta_pkg`](./ironclaw_beta) | glue |\n",
)
self.write(
"crates/core/ironclaw_alpha/Cargo.toml",
'[package]\nname = "ironclaw_alpha"\n',
)
self.write(
"crates/core/ironclaw_alpha/README.md",
"Entry point `src/lib.rs`; charter pinned by `tests/charter.rs`.\n",
)
self.write("crates/core/ironclaw_alpha/src/lib.rs", "\n")
self.write("crates/core/ironclaw_alpha/tests/charter.rs", "\n")
self.write(
"crates/core/ironclaw_beta/Cargo.toml", '[package]\nname = "beta_pkg"\n'
)
self.write(
"crates/core/ironclaw_beta/README.md",
"Driven by `ironclaw_alpha` (`src/lib.rs`).\n",
)
self.write("crates/core/ironclaw_beta/src/lib.rs", "\n")
def tracked(self) -> list[str]:
"""The fixture's tracked set; symlinks appear as `<path> -> <target>`."""
entries: list[str] = []
for path in self.root.rglob("*"):
if path.is_symlink():
rel = path.relative_to(self.root).as_posix()
entries.append(f"{rel} -> {os.readlink(path)}")
elif path.is_file():
entries.append(path.relative_to(self.root).as_posix())
return sorted(entries)
ROOT_ALIAS_EXCEPTION = {"CLAUDE.md": "root adapter (fixture)"}
def run_gate(
self,
tracked: list[str] | None = None,
known_missing: tuple | None = (),
alias_exceptions: dict[str, str] | None = None,
) -> tuple[int, str]:
listing = self.root / "tracked-files.txt"
listing.write_text(
"\n".join(t for t in (tracked if tracked is not None else self.tracked())
if t != "tracked-files.txt")
+ "\n",
encoding="utf-8",
)
patches = [
mock.patch.object(GATE, "MIN_GUIDANCE_FILES", 1),
mock.patch.object(GATE, "MIN_PATH_REFERENCES", 1),
mock.patch.object(GATE, "MIN_RULE_GLOBS", 1),
mock.patch.object(GATE, "MIN_ALIAS_PAIRS", 1),
mock.patch.object(
GATE,
"ALIAS_REAL_FILE_EXCEPTIONS",
dict(self.ROOT_ALIAS_EXCEPTION)
if alias_exceptions is None
else alias_exceptions,
),
mock.patch.object(crate_tree, "MIN_CRATE_DIRECTORIES", 1),
]
if known_missing is not None:
patches.append(mock.patch.object(GATE, "KNOWN_MISSING", known_missing))
stderr, stdout = io.StringIO(), io.StringIO()
with contextlib.ExitStack() as stack:
for patch in patches:
stack.enter_context(patch)
stack.enter_context(contextlib.redirect_stderr(stderr))
stack.enter_context(contextlib.redirect_stdout(stdout))
crate_tree.reset_inventory_cache()
code = GATE.main(
["--repo-root", str(self.root), "--tracked-files", str(listing)]
)
crate_tree.reset_inventory_cache()
return code, stderr.getvalue() + stdout.getvalue()
# -- the fixture itself must pass, or every refusal below is suspect --
def test_fixture_passes(self) -> None:
self.build_fixture()
code, output = self.run_gate()
self.assertEqual(code, 0, output)
self.assertIn("guidance: OK", output)
# -- check 1: references must resolve ---------------------------------
def test_dangling_backticked_reference_fails_naming_the_offender(self) -> None:
self.build_fixture()
self.write(
"crates/core/ironclaw_alpha/README.md",
"Entry point `src/lib.rs`; see `crates/core/ironclaw_gamma/README.md`.\n",
)
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("crates/core/ironclaw_alpha/README.md", output)
self.assertIn("crates/core/ironclaw_gamma/README.md", output)
def test_dangling_markdown_link_fails(self) -> None:
self.build_fixture()
self.write("AGENTS.md", "Start at [the family](crates/gone/AGENTS.md).\n")
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("crates/gone/AGENTS.md", output)
def test_crate_relative_and_context_citations_are_not_flagged(self) -> None:
"""The legitimate citation forms measured on the live tree: crate-
relative (`tests/charter.rs` in the crate's README), crate-context
("`ironclaw_alpha` (`src/lib.rs`)" in beta's README), fenced blocks,
placeholders, the `✎` glyph, the `path-ok` marker, and MCP method
names. The fixture passing (above) covers the first two; this case
stacks the suppressions on one document."""
self.build_fixture()
self.write(
"crates/core/ironclaw_alpha/README.md",
"Entry point `src/lib.rs`.\n\n"
"```text\ncrates/fenced/does_not_exist.rs\n```\n\n"
"Template: `crates/core/<crate>/README.md`.\n"
"✎ 2026-08-06: `crates/core/ironclaw_removed` was deleted.\n"
"Example `crates/core/ironclaw_example/README.md` "
"<!-- check-guidance: path-ok -->\n"
"The MCP `tools/list` method.\n",
)
code, output = self.run_gate()
self.assertEqual(code, 0, output)
def test_prefix_and_module_relative_citations_resolve(self) -> None:
self.build_fixture()
self.write("crates/core/ironclaw_alpha/src/hosted_mcp_discovery.rs", "\n")
self.write("crates/core/ironclaw_alpha/src/inner/tests/policy.rs", "\n")
self.write(
"crates/core/ironclaw_alpha/README.md",
"Vocabulary confined to `src/hosted_mcp_` modules; a policy test "
"belongs in `tests/policy.rs`.\n",
)
code, output = self.run_gate()
self.assertEqual(code, 0, output)
def test_path_ok_marker_covers_one_reference_not_the_line(self) -> None:
"""The marker vouches for the reference immediately before it — a
dangling path slipped onto an already-marked line (before or after
the marked reference) must still fail. The 2026-08-06 audit rode a
fresh dangling path in on exactly this line-blanket behavior."""
self.build_fixture()
self.write(
"crates/core/ironclaw_alpha/README.md",
"Entry point `src/lib.rs`.\n"
"See `crates/core/ironclaw_before/README.md` and the deliberate "
"`crates/core/ironclaw_marked/README.md` "
"<!-- check-guidance: path-ok --> "
"plus `crates/core/ironclaw_after/README.md`.\n",
)
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("crates/core/ironclaw_before/README.md", output)
self.assertIn("crates/core/ironclaw_after/README.md", output)
self.assertNotIn("crates/core/ironclaw_marked/README.md", output)
def test_suppression_row_that_outlived_its_debt_fails(self) -> None:
self.build_fixture()
row = GATE.Suppression(
doc="crates/core/ironclaw_alpha/README.md",
token="src/lib.rs",
reason="stale — this reference resolves",
)
code, output = self.run_gate(known_missing=(row,))
self.assertEqual(code, 1)
self.assertIn("Delete the row", output)
def test_suppressed_reference_warns_but_passes(self) -> None:
self.build_fixture()
self.write(
"crates/core/ironclaw_alpha/README.md",
"Entry point `src/lib.rs`; create `src/myprovider.rs` to extend.\n",
)
row = GATE.Suppression(
doc="crates/core/ironclaw_alpha/README.md",
token="src/myprovider.rs",
reason="instructional example",
)
code, output = self.run_gate(known_missing=(row,))
self.assertEqual(code, 0, output)
self.assertIn("grandfathered dangling reference", output)
self.assertIn("src/myprovider.rs", output)
def test_duplicate_known_missing_rows_fail(self) -> None:
"""Two rows excusing the same reference is a table bug: one debt, one
row. The dedup guard must fail rather than silently collapsing them."""
self.build_fixture()
self.write(
"crates/core/ironclaw_alpha/README.md",
"Entry point `src/lib.rs`; create `src/myprovider.rs` to extend.\n",
)
row = GATE.Suppression(
doc="crates/core/ironclaw_alpha/README.md",
token="src/myprovider.rs",
reason="instructional example",
)
twin = GATE.Suppression(
doc="crates/core/ironclaw_alpha/README.md",
token="src/myprovider.rs",
reason="same debt, second row",
)
code, output = self.run_gate(known_missing=(row, twin))
self.assertEqual(code, 1)
self.assertIn("one debt, one row", output)
# -- check 2: rule triggers must be live -------------------------------
def test_rule_glob_matching_nothing_fails_naming_rule_and_glob(self) -> None:
"""The `.claude/rules/skills.md` failure: a trigger naming a file
that does not exist, so the rule never fires."""
self.build_fixture()
self.write(
".claude/rules/alpha.md",
'---\npaths:\n - "crates/**/*.rs"\n - "crates/core/ironclaw_alpha/src/bundled_skills.rs"\n---\n',
)
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn(".claude/rules/alpha.md", output)
self.assertIn("bundled_skills.rs", output)
self.assertIn("never fires", output)
def test_brace_alternation_trigger_counts_as_live(self) -> None:
"""A `{rs,toml}` trigger matching real tracked files passes check 2
end-to-end — the false-dead-rule shape the translator fix closes."""
self.build_fixture()
self.write(
".claude/rules/alpha.md",
'---\npaths:\n - "crates/**/*.{rs,toml}"\n---\n',
)
code, output = self.run_gate()
self.assertEqual(code, 0, output)
def test_skill_frontmatter_paths_are_checked_too(self) -> None:
self.build_fixture()
self.write(
".claude/skills/demo/SKILL.md",
'---\nname: demo\npaths:\n - "crates/nowhere/**"\n---\n',
)
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn(".claude/skills/demo/SKILL.md", output)
self.assertIn("crates/nowhere/**", output)
def test_unterminated_frontmatter_refuses(self) -> None:
self.build_fixture()
self.write(".claude/rules/alpha.md", "---\npaths:\n - \"crates/**\"\n")
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("never closed", output)
def test_paths_key_with_no_globs_refuses(self) -> None:
self.build_fixture()
self.write(".claude/rules/alpha.md", "---\npaths:\n---\n# empty\n")
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("lists no globs", output)
def test_inline_paths_value_refuses_rather_than_skipping(self) -> None:
self.build_fixture()
self.write(".claude/rules/alpha.md", '---\npaths: "crates/**"\n---\n')
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("block lists only", output)
def test_glob_translation_handles_the_repo_shapes(self) -> None:
cases = {
("crates/**/*.rs", "crates/core/ironclaw_alpha/src/lib.rs"): True,
("crates/**/*.rs", "crates/top.rs"): True,
("crates/**/*.rs", "tests/lib.rs"): False,
("**/Cargo.toml", "Cargo.toml"): True,
("**/Cargo.toml", "crates/core/ironclaw_alpha/Cargo.toml"): True,
("crates/core/ironclaw_alpha/**", "crates/core/ironclaw_alpha/src/lib.rs"): True,
("crates/core/ironclaw_alpha/**", "crates/core/ironclaw_alpha"): False,
("skills/**", "skills/demo/SKILL.md"): True,
("crates/core/ironclaw_alpha/src/lib.rs", "crates/core/ironclaw_alpha/src/lib.rs"): True,
# Brace alternation: a legitimate `{rs,toml}` trigger is live, not
# a dead literal (#7306 review).
("crates/**/*.{rs,toml}", "crates/core/ironclaw_alpha/src/lib.rs"): True,
("crates/**/*.{rs,toml}", "crates/core/ironclaw_alpha/Cargo.toml"): True,
("crates/**/*.{rs,toml}", "crates/core/ironclaw_alpha/README.md"): False,
("{crates,tests}/**", "tests/lib.rs"): True,
("{crates,tests}/**", "docs/lib.rs"): False,
("crates/{core/**,README.md}", "crates/core/ironclaw_alpha/src/lib.rs"): True,
("crates/{core/**,README.md}", "crates/README.md"): True,
("src/{a,{b,c}}.rs", "src/b.rs"): True,
("src/{a,{b,c}}.rs", "src/d.rs"): False,
# An unmatched brace stays a literal character.
("src/un{matched.rs", "src/un{matched.rs"): True,
}
for (glob, path), expected in cases.items():
with self.subTest(glob=glob, path=path):
self.assertIs(
GATE.glob_to_regex(glob).match(path) is not None, expected
)
# -- checks 3 and 4: family tables and READMEs --------------------------
def test_crate_missing_from_family_table_fails(self) -> None:
self.build_fixture()
self.write(
"crates/core/AGENTS.md",
"# core family\n\n| Crate | Charter |\n| --- | --- |\n"
"| [`ironclaw_alpha`](./ironclaw_alpha) | records |\n",
)
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("crates/core/ironclaw_beta", output)
self.assertIn("crates/core/AGENTS.md", output)
self.assertIn("family table no longer covers", output)
def test_incidental_mention_in_another_row_is_not_coverage(self) -> None:
"""A crate whose own row is gone must fail even when another row's
charter prose still names it — only the identity column counts."""
self.build_fixture()
self.write(
"crates/core/AGENTS.md",
"# core family\n\n| Crate | Charter |\n| --- | --- |\n"
"| [`ironclaw_alpha`](./ironclaw_alpha) | records; routes to "
"`ironclaw_beta` for storage |\n",
)
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("crates/core/ironclaw_beta", output)
self.assertIn("identity (first) column", output)
def test_table_match_accepts_package_name_for_renamed_directories(self) -> None:
"""`beta_pkg` (the package) is in the table, `ironclaw_beta` (the
directory) is not — the fixture passing at all pins this."""
self.build_fixture()
code, output = self.run_gate()
self.assertEqual(code, 0, output)
def test_family_without_agents_md_fails(self) -> None:
self.build_fixture()
self.write("crates/solo/ironclaw_gamma/Cargo.toml", '[package]\nname = "ironclaw_gamma"\n')
self.write("crates/solo/ironclaw_gamma/README.md", "gamma\n")
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("crates/solo/AGENTS.md", output)
def test_crate_without_readme_fails(self) -> None:
self.build_fixture()
tracked = [t for t in self.tracked() if t != "crates/core/ironclaw_beta/README.md"]
code, output = self.run_gate(tracked=tracked)
self.assertEqual(code, 1)
self.assertIn("crates/core/ironclaw_beta", output)
self.assertIn("no tracked README.md", output)
# -- check 5: the CLAUDE.md alias rule ---------------------------------
def test_alias_deleted_from_the_index_fails_naming_the_pair(self) -> None:
"""The audit's exploit: a committed symlink deletion left the gate
green. Removing the alias from the *tracked set* (the disk copy may
even survive) must fail, naming both halves of the pair."""
self.build_fixture()
tracked = [
t for t in self.tracked() if not t.startswith("crates/core/CLAUDE.md")
]
code, output = self.run_gate(tracked=tracked)
self.assertEqual(code, 1)
self.assertIn("crates/core/AGENTS.md has no tracked CLAUDE.md", output)
self.assertIn("ln -s AGENTS.md crates/core/CLAUDE.md", output)
def test_alias_as_regular_file_fails(self) -> None:
"""A copied real file drifts; only a mode-120000 symlink (or a named
exception row) is a legal alias."""
self.build_fixture()
(self.root / "crates/core/CLAUDE.md").unlink()
self.write("crates/core/CLAUDE.md", "# core family\n\ncopied bytes\n")
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn(
"crates/core/CLAUDE.md is tracked as a regular file, not a symlink",
output,
)
self.assertIn("ALIAS_REAL_FILE_EXCEPTIONS", output)
def test_alias_with_wrong_target_fails(self) -> None:
"""The target must be the bare sibling string; a link that resolves
to *an* AGENTS.md via another route still fails. (`../../AGENTS.md`
reaches the fixture root's real file, so the content read stays
healthy and the refusal is the alias check's, not a read error.)"""
self.build_fixture()
self.symlink("crates/core/CLAUDE.md", "../../AGENTS.md")
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("'../../AGENTS.md'", output)
self.assertIn("must target the sibling `AGENTS.md`", output)
def test_root_real_file_exception_row_is_load_bearing(self) -> None:
"""The fixture's root CLAUDE.md is a real file and passes only via
the patched exception row; with the row gone the gate must flag it."""
self.build_fixture()
code, output = self.run_gate(alias_exceptions={})
self.assertEqual(code, 1)
self.assertIn("CLAUDE.md is tracked as a regular file", output)
def test_alias_exception_rows_must_match_reality(self) -> None:
self.build_fixture()
# A row excusing an alias that is actually a symlink is stale.
code, output = self.run_gate(
alias_exceptions={
**self.ROOT_ALIAS_EXCEPTION,
"crates/core/CLAUDE.md": "pretend reason",
}
)
self.assertEqual(code, 1)
self.assertIn("tracked as a symlink, yet it is a named real-file", output)
# A row whose directory has no AGENTS.md at all excuses nothing.
code, output = self.run_gate(
alias_exceptions={
**self.ROOT_ALIAS_EXCEPTION,
"crates/core/ironclaw_alpha/CLAUDE.md": "stale row",
}
)
self.assertEqual(code, 1)
self.assertIn("no AGENTS.md sits beside it", output)
self.assertIn("delete it", output)
# -- the gate must not fail open ---------------------------------------
def test_unreadable_guidance_file_refuses(self) -> None:
self.build_fixture()
tracked = self.tracked()
target = self.root / "crates/core/ironclaw_alpha/README.md"
target.unlink()
target.mkdir() # tracked as a file, unreadable as one on disk
code, output = self.run_gate(tracked=tracked)
self.assertEqual(code, 1)
self.assertIn("cannot read guidance file", output)
def test_missing_root_guidance_refuses(self) -> None:
self.build_fixture()
tracked = [t for t in self.tracked() if t != "CLAUDE.md"]
code, output = self.run_gate(tracked=tracked)
self.assertEqual(code, 1)
self.assertIn("CLAUDE.md is not tracked", output)
def test_extraction_floor_refuses_an_empty_scan(self) -> None:
self.build_fixture()
with mock.patch.object(GATE, "MIN_PATH_REFERENCES", 10_000):
listing = self.root / "tracked-files.txt"
listing.write_text("\n".join(self.tracked()) + "\n", encoding="utf-8")
stderr = io.StringIO()
with (
mock.patch.object(GATE, "MIN_GUIDANCE_FILES", 1),
mock.patch.object(GATE, "KNOWN_MISSING", ()),
mock.patch.object(crate_tree, "MIN_CRATE_DIRECTORIES", 1),
contextlib.redirect_stderr(stderr),
contextlib.redirect_stdout(io.StringIO()),
):
crate_tree.reset_inventory_cache()
code = GATE.main(
["--repo-root", str(self.root), "--tracked-files", str(listing)]
)
crate_tree.reset_inventory_cache()
self.assertEqual(code, 1)
self.assertIn("refusing rather than verifying almost nothing", stderr.getvalue())
def test_unterminated_fence_refuses(self) -> None:
self.build_fixture()
self.write(
"crates/core/ironclaw_alpha/README.md",
"Entry `src/lib.rs`.\n```text\nnever closed\n",
)
code, output = self.run_gate()
self.assertEqual(code, 1)
self.assertIn("unterminated ``` fence", output)
def test_missing_tracked_files_override_refuses(self) -> None:
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = GATE.main(
[
"--repo-root",
str(self.root),
"--tracked-files",
str(self.root / "nonexistent.txt"),
]
)
self.assertEqual(code, 1)
self.assertIn("cannot read --tracked-files", stderr.getvalue())
# -- happy path, against the real repository ---------------------------
def test_real_repository_guidance_is_clean(self) -> None:
"""Deliberately coupled to `git` and this checkout: no
`--tracked-files` override, so `GATE.main` shells out to
`git ls-files -s` against the real repository. That coupling is the
point — it proves the production entry path (subprocess, index
parsing, symlink blobs) on real data, which no fixture run exercises.
Consequence: outside a git checkout (a source export, a tarball) this
case fails with "the tracked set cannot be read" — an environment
failure, not guidance drift. The separate `Check guidance references
the tracked tree` CI step runs the same command as the gate; this
case exists so the self-test suite alone still covers the git leg."""
stderr, stdout = io.StringIO(), io.StringIO()
with contextlib.redirect_stderr(stderr), contextlib.redirect_stdout(stdout):
crate_tree.reset_inventory_cache()
code = GATE.main(["--repo-root", str(REPO_ROOT)])
crate_tree.reset_inventory_cache()
output = stderr.getvalue() + stdout.getvalue()
self.assertEqual(code, 0, output)
self.assertIn("guidance: OK", output)
if __name__ == "__main__":
unittest.main()