scripts.check_spec_authority
Reference › scripts › check_spec_authority
The spec-authority gate (RUL-013): a @traces decorator on a test may cite only a requirement id that some spec defines. It fails when a test cites an id that no spec contains. tests/scripts/test_check_spec_authority.py imports it.
Invocation: main takes --roots (what to scan; defaults are built in), --specs-dir (the spec corpus, the ground truth), --max (a ratchet ceiling on the number of ungrounded ids; default 0, to be left at zero once known fabrications are resolved) and --json. Relative paths resolve against the repository root, which repo_root finds.
find_violations loads the deployed AST toolkit (load_ast_toolkit) to read decorators without importing test code, keeps only test symbols (is_test_symbol) and compares each cited id with broad_ground_truth, the set of ids defined anywhere in the specs directory. Rows are reported with display paths (_display_path) and the run fails when their count exceeds the ceiling.
At a glance
| Source | scripts/check_spec_authority.py |
| Lines of code | 283 |
| Classes | 0 |
| Functions | 7 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | tests/scripts/test_check_spec_authority.py |
Class diagram
No classes; see Functions.
Dependency diagram
No internal imports and no importers.
API
Constants
| Name | Type | Value |
|---|---|---|
DEFAULT_ROOTS |
tuple[str, ...] |
('tests',) |
Functions
| Function | Signature | Summary |
|---|---|---|
repo_root |
repo_root(env: Mapping[str, str] | None = None) -> Path |
Repo the gate is pointed at. ORCHESTRATA_ROOT wins (same seam as scripts/check_req_format.py); otherwise the checkout this file is in. |
load_ast_toolkit |
load_ast_toolkit(tool_path: Path = _TOOL_PATH) -> Any |
Load scripts/serialize_codebase_ast.py by path — scripts/ is not an importable package, so this is the established pattern (see scripts/ast_report.py::_load_tool). |
broad_ground_truth |
broad_ground_truth(specs_dir: Path) -> set[str] |
Every REQ id mentioned anywhere in <specs_dir>/**/*.md. |
is_test_symbol |
is_test_symbol(name: str) -> bool |
A pytest-collected symbol. |
find_violations |
find_violations(roots: Sequence[Path], specs_dir: Path, tool: Any | None = None) -> list[dict[str, Any]] |
Requirement ids cited by a test decorator and defined by no spec. |
_display_path |
_display_path(raw: str, repo: Path) -> str |
|
main |
main(argv: Sequence[str] | None = None) -> int |
Source
#!/usr/bin/env python3
"""Spec-authority gate (RUL-013) — a requirement id cited by a TEST must be
grounded in the spec corpus.
RUL-013 has two halves. The workflow half ("an agent asked the user instead of
reading the spec") is unmechanizable and stays judgment. The EVIDENCE half is
deterministic: when someone resolves a contract from a test instead of from a
spec, they must invent a requirement id to cite, because no spec defines the
behaviour they are asserting. That fabricated id is a permanent, corpus-wide,
machine-checkable artifact — and until this gate, nothing looked for it in
``tests/``.
WHAT THIS GATE DOES
Runs the AST toolkit's ``trace_lint`` over ``tests/`` (REUSED by path, not
reimplemented — the same pattern as ``scripts/ast_report.py::_load_tool``
and ``scripts/trace_intel.py::_load_parse_spec_requirements``) and keeps
ONLY findings where::
rule == "dangling" and the decorated symbol is a test
(name starts with ``test_`` / ``Test``)
Ratchets on ``--max``, mirroring ``scripts/check_req_format.py --max`` and
``scripts/pyright_ratchet.py`` (now in codetools:
src/codetools/refactor/pyright/ratchet.py). Exit 1 when the count exceeds the ceiling.
WHAT IT DELIBERATELY DOES NOT FLAG (each verified against the live tree; each
would be a false accusation)
* Fixture classes that exercise the ``@traces`` machinery itself
(``tests/core/test_traceability.py::Bad``, ``tests/test_core.py::Foo`` and
``::ConcreteEntity``). Their ids are synthetic ON PURPOSE — flagging them
would demand defanging the rejection tests. The ``test_``/``Test`` naming
rule excludes all three structurally, with no hand-maintained allowlist,
because a fixture class is never named ``test_*``.
* ``spec-mismatch``. Those ids are REAL (e.g. the REQ-002 family lives in
``specs/001-kernel/checklists/requirements.md``); only the decorator's
``spec=`` label is stale. Including that rule would manufacture false
accusations against correct tests citing correct requirements.
* ``shotgun`` / ``malformed`` / ``placeholder`` — style and format concerns
already owned by ``scripts/check_req_format.py`` and trace-lint's src lane.
* ``src/orchestrata_ultimate`` — out of scope. This ruling's distinctive
clause is "never from a TEST"; src has its own separate trace-lint
backlog.
* REQ ids in docstrings, comments and string literals — only real
``@traces(...)`` decorator nodes are read.
GROUND-TRUTH HARDENING (load-bearing)
``trace_lint`` grounds ids in ``specs/*/checklists/requirements.md`` only.
A spec that documents its requirements as prose, or whose checklist has not
landed yet, would make legitimate ids look dangling. This gate therefore
unions in the BROAD corpus set — any ``REQ-`` token in any ``specs/**/*.md``
— before reporting. Measured 2026-08-05: broad 5540 ids, narrow checklist
5413, and the broad set is a strict superset. That makes false accusation
structurally impossible: the gate can only ever UNDER-report a real
fabrication, never invent one. An under-reporting gate is honest; an
over-reporting one poisons the well and gets switched off.
Wired in ``scripts/health_check.py`` alongside the other corpus-scoped ruling
gates. Not a git hook: the check needs the whole spec ground truth plus the
whole ``tests/`` tree, so a per-staged-file pass is the wrong shape.
Usage::
python scripts/check_spec_authority.py # ceiling 0
python scripts/check_spec_authority.py --max 8 # ratchet
python scripts/check_spec_authority.py --json
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import re
import sys
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
# The ratified ruling this gate enforces, verbatim. ``scripts/rulings_lint.py``
# resolves a ``- gate <script> <rule-id>`` directive by requiring <rule-id> to
# be a literal substring of this file, so the full text lives here — and it
# doubles as the honest statement of what the gate does and does not claim.
RULING = """RUL-013 — "Specs are authoritative — resolve behaviour/contract decisions from specs/SPEC-000..105 + @traces(reqs=[...]), never from a test and never from a guess."
VERDICT: mechanizable, in one narrow but real half — and the violation it catches is sitting in the tree right now, undetected by every existing gate.
The ruling has two halves. The workflow half ("an agent asked the user instead of reading the spec") is genuinely unmechanizable, exactly as the page's Verification section already says. The EVIDENCE half is not: when someone resolves a contract from a test instead of from a spec, they leave a permanent artifact — a requirement id cited by a test that no spec defines. That artifact is deterministic, corpus-wide, and currently ungated.
MEASURED PROOF THE VIOLATION IS REAL AND LIVE:
specs/003-intent/checklists/requirements.md defines REQ-003-FR24-001 .. -004. Nothing else. Yet eight test functions across six files carry:
@traces(spec="003", story="FR-024", reqs=["REQ-003-FR24-100"]) ... through "-106"
tests/e2e/test_full_pipeline.py:85, tests/orchestrator/test_pipeline_e2e.py:{51,60,80},
tests/orchestrator/test_pipeline_e2e_analysis.py:43, tests/orchestrator/test_pipeline_e2e_docs_session.py:{65,90},
tests/orchestrator/test_pipeline_e2e_review.py:34
Seven requirement ids that exist in NO spec, anywhere (grep of specs/ src/ plans/ docs/ returns nothing). Someone needed a requirement to cite for the pipeline e2e tests, spec 003 did not define one, so they invented a plausible continuation of the family. That is RUL-013's failure mode verbatim: the contract was resolved from a test.
WHY NO EXISTING GATE SEES IT (I read all three before designing):
- scripts/check_req_format.py checks GRAMMAR only. REQ-003-FR24-107 matches its canonical `{SPEC}-{SEG}{IDX}-{SEQ}` production perfectly. It passes today at --max 0 (rc=0, verified) and is structurally blind to whether an id EXISTS.
- serialize_codebase_ast.py trace-lint HAS the right rule (`dangling`) but is scoped to src/orchestrata_ultimate only (AGENTS.md two-lane rule, echoed at code-review.py:1331), currently reports 120 errors there, and — this is the point — is invoked by NOTHING: no health_check entry, no git hook, no CI. It is a built-but-never-used capability, the same defect RUL-023 names.
- trace_intel.py untraced-reqs / unspecced run the INVERSE direction (spec ids with no code) and are advisory.
So tests/ is completely ungated for requirement groundedness. That is the RUL-013 gap, and it is the one lane where the ruling's "never from a test" clause actually bites.
DESIGN: scripts/check_spec_authority.py — a thin gate that REUSES trace_lint (loaded by path, the established scripts/ pattern from trace_intel._load_parse_spec_requirements and ast_report._load_tool), runs it over tests/, and keeps only findings where rule == "dangling" AND the decorated symbol name starts with test_/Test. Ratchet via --max, mirroring check_req_format --max and pyright_ratchet (now in codetools: src/codetools/refactor/pyright/ratchet.py).
One hardening beyond stock trace_lint: use the BROAD ground truth (any REQ- token in any specs/**/*.md), not just specs/*/checklists/requirements.md. Measured: broad = 5505 ids, narrow checklist = 5413. All seven fabricated ids are absent from BOTH, so the gap does not bite today — but a future prose-only spec would make legitimate ids look dangling. Widening the ground truth makes false accusation structurally impossible: the gate can only ever under-report, never manufacture a violation.
LANDING PLAN: wire at --max 8 (today's exact count) so it fires on any NEW fabrication from day one; fix the seven ids; pin --max 0.
DECISION FOR THE USER (surfacing, not deferring): the RUL-013-correct fix for the seven ids is to WRITE THE SPEC — add REQ-003-FR24-005..011 to specs/003-intent/checklists/requirements.md describing the pipeline e2e behaviours those tests genuinely verify — rather than re-pointing the tests at unrelated existing ids. The tests describe real behaviour spec 003 failed to write down; the ruling says resolve from the spec, so the spec is what should change. That edits the authoritative spec corpus, so it is your call, not mine.
COLLATERAL BROKEN WINDOW FOUND (separate from RUL-013, reporting per fix-on-sight): .pre-commit-config.yaml's `req-id-format` hook — and every other hook in that file — NEVER EXECUTES in this checkout. `git config core.hooksPath` = scripts/hooks, which shadows .git/hooks entirely, and scripts/hooks/pre-commit never calls `pre-commit run` (grep count: 0). docs/req-id-grammar.md:7 claims check_req_format is "enforced as pre-commit hook req-id-format, pinned --max 0". That claim is false. Nothing has drifted yet (manual run is green), but the enforcement is imaginary. Fix: add check_req_format.py --max 0 to health_check.py's gate list, or make scripts/hooks/pre-commit shell out to `pre-commit run`."""
# The AST toolkit is resolved from THIS file's directory, never from
# ORCHESTRATA_ROOT: the gate must keep using the real toolkit even when it is
# pointed at a throwaway corpus (which is exactly how its own tests drive it).
_SCRIPTS_DIR = Path(__file__).resolve().parent
_TOOL_PATH = _SCRIPTS_DIR / "serialize_codebase_ast.py"
DEFAULT_ROOTS: tuple[str, ...] = ("tests",)
DEFAULT_SPECS_DIR = "specs"
# Deliberately looser than trace-lint's checklist-shape ground truth: ANY REQ
# token, in ANY spec markdown. See "GROUND-TRUTH HARDENING" above.
_BROAD_REQ_RE = re.compile(r"\b(REQ-[A-Za-z0-9][A-Za-z0-9-]*)")
# The one rule of trace-lint that is RUL-013's subject. See the module
# docstring for why every other rule is excluded rather than merely unused.
DANGLING_RULE = "dangling"
def repo_root(env: Mapping[str, str] | None = None) -> Path:
"""Repo the gate is pointed at. ``ORCHESTRATA_ROOT`` wins (same seam as
``scripts/check_req_format.py``); otherwise the checkout this file is in."""
environ = os.environ if env is None else env
raw = environ.get("ORCHESTRATA_ROOT", "")
return Path(raw).resolve() if raw else _SCRIPTS_DIR.parent
def load_ast_toolkit(tool_path: Path = _TOOL_PATH) -> Any:
"""Load ``scripts/serialize_codebase_ast.py`` by path — ``scripts/`` is not
an importable package, so this is the established pattern (see
``scripts/ast_report.py::_load_tool``)."""
spec = importlib.util.spec_from_file_location("serialize_codebase_ast", tool_path)
if spec is None or spec.loader is None:
raise ImportError(f"cannot load AST toolkit from {tool_path}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def broad_ground_truth(specs_dir: Path) -> set[str]:
"""Every REQ id mentioned anywhere in ``<specs_dir>/**/*.md``."""
ids: set[str] = set()
if not specs_dir.is_dir():
return ids
for path in sorted(specs_dir.rglob("*.md")):
try:
text = path.read_text(encoding="utf-8")
except OSError, UnicodeDecodeError:
continue
ids.update(m.group(1) for m in _BROAD_REQ_RE.finditer(text))
return ids
def is_test_symbol(name: str) -> bool:
"""A pytest-collected symbol. Excludes the fixture classes that exist to
exercise the ``@traces`` decorator itself — those are never named
``test_*``, so no allowlist has to be maintained by hand."""
return name.startswith("test_") or name.startswith("Test")
def find_violations(
roots: Sequence[Path],
specs_dir: Path,
tool: Any | None = None,
) -> list[dict[str, Any]]:
"""Requirement ids cited by a test decorator and defined by no spec."""
present = [str(r) for r in roots if r.exists()]
if not present:
return []
toolkit = load_ast_toolkit() if tool is None else tool
result = toolkit.trace_lint(present, specs_dir=str(specs_dir))
grounded = broad_ground_truth(specs_dir)
violations = [
finding
for finding in result["findings"]
if finding["rule"] == DANGLING_RULE
and is_test_symbol(finding["name"])
and finding["detail"] not in grounded
]
return sorted(violations, key=lambda f: (f["file"], f["line"], f["detail"]))
def _display_path(raw: str, repo: Path) -> str:
try:
return str(Path(raw).resolve().relative_to(repo))
except ValueError:
return raw
def main(argv: Sequence[str] | None = None) -> int:
ap = argparse.ArgumentParser(
description=(
"Spec-authority gate (RUL-013): fail when a @traces decorator on a "
"test cites a requirement id that no spec defines."
)
)
ap.add_argument(
"--roots",
nargs="*",
default=None,
help=f"Roots to scan (default: {' '.join(DEFAULT_ROOTS)})",
)
ap.add_argument(
"--specs-dir",
default=DEFAULT_SPECS_DIR,
help=f"Spec corpus providing the ground truth (default: {DEFAULT_SPECS_DIR})",
)
ap.add_argument(
"--max",
type=int,
default=0,
help=(
"Fail if the ungrounded-id count exceeds this (ratchet ceiling; "
"pin 0 once the known fabrications are resolved)."
),
)
ap.add_argument("--json", action="store_true", help="JSON output.")
args = ap.parse_args(argv)
repo = repo_root()
def _resolve(rel: str) -> Path:
path = Path(rel)
return path if path.is_absolute() else repo / path
root_names = DEFAULT_ROOTS if args.roots is None else tuple(args.roots)
roots = [_resolve(r) for r in root_names]
specs_dir = _resolve(args.specs_dir)
violations = find_violations(roots, specs_dir)
rows = [
{
"file": _display_path(v["file"], repo),
"line": v["line"],
"name": v["name"],
"req_id": v["detail"],
}
for v in violations
]
if args.json:
print(json.dumps({"violations": rows, "count": len(rows), "max": args.max}))
else:
print(
f"Spec-authority gate (RUL-013): {len(rows)} test-cited requirement "
f"id(s) defined by no spec; ceiling --max {args.max}."
)
for row in rows:
print(
f" {row['file']}:{row['line']}: {row['name']} cites "
f"{row['req_id']} — defined by no spec under {args.specs_dir}/"
)
if len(rows) > args.max:
print(
f"FAIL: {len(rows)} ungrounded requirement id(s) cited by tests "
f"exceeds the allowed {args.max}. Specs are authoritative "
"(RUL-013): write the requirement into the spec, then cite it — "
"never invent an id to satisfy a test.",
file=sys.stderr,
)
return 1
print(
"PASS: every test-cited requirement id is grounded in a spec.", file=sys.stderr
)
return 0
if __name__ == "__main__":
raise SystemExit(main())