scripts.check_generated_artifacts
Reference › scripts › check_generated_artifacts
The generated-artifacts gate, the mechanism for ruling RUL-030: generated artifacts belong in git. It is non-waivable, with no warn-only mode and no inline override. Three test modules import it: tests/scripts/test_affected_manifest_rules.py, tests/scripts/test_check_generated_artifacts.py and tests/scripts/test_rtm_evidence_paths.py.
Invocation: main takes only --json. It refuses to pass (exit invalid) if the artifact registry is empty, since an empty registry would mean nothing was checked. It runs four checks and merges their violations:
check: each registeredArtifact(expanded by_expand) must be tracked by git and not ignored (_is_tracked,_is_ignored).check_generators_exist: the generator named for each artifact must exist. A missing one is reported as a loss or as never having existed, using git history (_git_deleted_path,_provenance_violation).check_generator_headers: files under tests, src and docs whose header names a generator must point at one that exists (scan_generator_headers).check_atomic_writes: generators must not write their artifact with a truncating write (_truncating_writes).
Exclusion entries record files that look generated but are not.
At a glance
| Source | scripts/check_generated_artifacts.py |
| Lines of code | 1314 |
| Classes | 2 |
| Functions | 19 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | tests/scripts/test_affected_manifest_rules.py, tests/scripts/test_check_generated_artifacts.py, tests/scripts/test_rtm_evidence_paths.py |
Class diagram
Dependency diagram
No internal imports and no importers.
API
Constants
| Name | Type | Value |
|---|---|---|
GENERATED_ARTIFACTS |
tuple[Artifact, ...] |
(Artifact('plans/ast-ultimate.json', 'scripts/serialize_codebase_ast.py seriali… |
NOT_ARTIFACTS |
tuple[Exclusion, ...] |
(Exclusion('*.warmcache', 'machine-specific pickled AST cache, content-sha1 sel… |
Classes
Artifact (dataclass)
Decorators: dataclass(frozen=True) · Line 158
A generated file that RUL-030 requires git to track.
| Attribute | Type | Default |
|---|---|---|
path |
str |
|
generator |
str |
|
consumer |
str |
|
runtime_input |
bool |
False |
Exclusion (dataclass)
Decorators: dataclass(frozen=True) · Line 187
A machine-written path that is deliberately NOT an artifact.
| Attribute | Type | Default |
|---|---|---|
path |
str |
|
reason |
str |
Functions
| Function | Signature | Summary |
|---|---|---|
_expand |
_expand(artifact: Artifact, repo_root: Path) -> list[tuple[Artifact, str]] |
Resolve one registry entry to the concrete paths to check. |
_git |
_git(args: list[str], repo_root: Path) -> subprocess.CompletedProcess[str] |
|
_is_ignored |
_is_ignored(path: str, repo_root: Path) -> str | None |
The .gitignore rule excluding path, or None. |
_is_tracked |
_is_tracked(path: str, repo_root: Path) -> bool |
|
check |
check(artifacts: tuple[Artifact, ...] | None = None, repo_root: Path | None = None) -> tuple[list[str], int] |
Return (violations, examined). |
_generator_path_hint |
_generator_path_hint(generator: str) -> str | None |
The leading repo-relative path in generator, if it looks like one. |
_generator_path_candidates |
_generator_path_candidates(token: str) -> tuple[str, ...] |
Repo-relative path strings worth trying for token. |
_git_deleted_path |
_git_deleted_path(token: str, repo_root: Path) -> tuple[str, str, str] | None |
If a path matching token was DELETED from git history, return (sha, date, path) for the commit that deleted it -- proof of LOSS rather than NEVER-EXISTED (see :data:_LOSS_MARKER). |
_related_requirements |
_related_requirements(paths: list[str], repo_root: Path) -> list[str] |
Best-effort REQ-* ids that paths serve, read from docs/rtm.json -- the ONE materialized traceability corpus this project already maintains (core/traceability_utils.py save_rtm(), the… |
_provenance_violation |
_provenance_violation(generator: str, paths: list[str], repo_root: Path) -> str |
One loud, actionable line for a generator that does not resolve. |
check_generators_exist |
check_generators_exist(artifacts: tuple[Artifact, ...] | None = None, repo_root: Path | None = None) -> tuple[list[str], int] |
Return (violations, examined) for the FORWARD provenance check. |
_read_header |
_read_header(path: Path, window: int = _HEADER_WINDOW) -> str |
The first window lines of path, decoded best-effort. |
_extract_header_generator |
_extract_header_generator(head: str) -> str | None |
The producer named by a header's "Generated by X" claim, or None if the header makes no such claim (or only a prose one -- see :data:_HEADER_GENERATOR_RE). |
_generator_reference_exists |
_generator_reference_exists(token: str, repo_root: Path) -> bool |
Resolve a header-claimed generator name to real source on disk. |
scan_generator_headers |
scan_generator_headers(repo_root: Path | None = None, source_dirs: tuple[str, ...] = ('tests', 'src', 'docs')) -> dict[str, list[str]] |
Map every header-claimed generator to the files that name it. |
check_generator_headers |
check_generator_headers(repo_root: Path | None = None, source_dirs: tuple[str, ...] = ('tests', 'src', 'docs')) -> tuple[list[str], int] |
Return (violations, examined) for the header-DISCOVERY check. |
_truncating_writes |
_truncating_writes(source: str, basename: str) -> list[int] |
Line numbers where source truncates its declared artifact in place. |
check_atomic_writes |
check_atomic_writes(repo_root: Path | None = None, artifacts: tuple[Artifact, ...] | None = None) -> tuple[list[str], int] |
Return (violations, examined) — no generator may truncate in place. |
main |
main(argv: list[str] | None = None) -> int |
Source
#!/usr/bin/env python3
"""Generated-artifacts gate — the mechanism for ruling **RUL-030**.
User ruling, 2026-08-13: *"generated artifacts belong in git."*
The dispute this settles is concrete. ``plans/ast-*.json`` and
``reports/run-tests-latest.json`` were committed by some sessions
(``66cd4191``, ``6626dee4``) and deliberately withheld by others, so the
working tree carried a permanent diff nobody could resolve, and a reader
could not tell whether a missing snapshot meant "not regenerated" or "we
don't keep those". A generated artifact is a **snapshot of project state at
a commit**; if it is not in the commit, the commit does not describe the
project.
THE BOUNDARY
------------
"Generated" cannot mean *every machine-written byte* --
``reports/test-durations.ndjson`` is 282 MB of append-only per-run telemetry
and ``*.warmcache`` is a machine-specific pickle. A ruling with no boundary
is unenforceable, so this gate applies a mechanical test:
An artifact belongs in git when it is a DERIVED SNAPSHOT OF PROJECT
STATE that a human or the code READS, and whose content is a function of
the commit rather than of the machine or the run.
Three properties, all three required:
1. **Derived** -- reproducible by running a named generator, never hand-authored.
2. **Read** -- something consumes it: a gate, a doc @import, a report, a human.
3. **A function of the commit** -- regenerating it at the same commit on
another machine yields the same content. This is what excludes
machine-local caches, per-run telemetry, wall-clock logs and locks.
Anything failing (3) is not an artifact for this purpose; it is a cache, a
log or runtime state. Those are listed in :data:`NOT_ARTIFACTS` **with a
reason**, so the exclusions are visible and auditable rather than implicit
in a ``.gitignore`` line nobody reads.
WHAT IT CHECKS
--------------
For every entry in :data:`GENERATED_ARTIFACTS`:
* **ignored** -- ``git check-ignore`` matches it. This is the violation the
ruling names: an ignore rule permanently keeps a generated artifact out of
git. Reported with the offending ``.gitignore`` line.
* **untracked** -- it exists on disk but git does not track it. It was
generated and never added.
A tracked artifact PASSES whether or not it is currently dirty: regeneration
cadence is a workflow question, not this ruling. Requiring clean would make
every session red the moment a hook regenerates the codebase map.
THE OTHER DIRECTION
--------------------
RUL-030 makes artifacts stay IN git. It says nothing about the GENERATOR
that produces them staying in the tree too -- and on 2026-07-09, commit
``6a186c9d`` deleted ``scripts/ast_serializer/testgen/`` (9 files, 2,392
lines) with the rationale "audited, zero consumers", because the audit
equated *no importer* with *no consumer*. For a generator, the consumer is
its OUTPUT, not its callers: ~100 files under ``tests/generated_recovery/``
still named ``ast_serializer.testgen`` in their headers, but that directory
sits under ``--ignore=tests/generated_recovery`` in pytest addopts, so
nothing ever executed those files and nothing went red. The deletion was
invisible, and the artifacts' own "Edit freely -- this file is NOT
regenerated automatically" header told the auditor the generator was
dispensable. Self-fulfilling.
The missing invariant is bidirectional provenance: an artifact must name its
generator in machine-readable form, AND a generator must not be deletable
while artifacts still referencing it remain in the tree. This gate checks
that invariant two ways:
* **forward** (:func:`check_generators_exist`) -- for every
:data:`GENERATED_ARTIFACTS` entry whose ``generator`` field names a
checkable repo-relative path, that path must exist. Cheap, but only as
honest as the registry: an entry nobody bothered to register is invisible
to it.
* **discovery** (:func:`check_generator_headers`) -- the direction that
would actually have caught 6a186c9d, because it does not trust the
registry at all. Every file under ``tests/``, ``src/``, ``docs/`` is
scanned for a header CLAIMING machine generation ("Generated by X",
"generated-by: X"), and the named producer must exist on disk. A registry
entry can be stale or simply missing; a file's own header cannot be
argued with -- it is the artifact making the claim about itself.
AUTHORITY -- and why there is no escape hatch
----------------------------------------------
Both directions are ruling **RUL-030**: "generated artifacts belong in git"
already presupposes a generator exists to have produced them, so a generator
vanishing out from under its artifacts is the SAME defect the ruling names,
seen from the other side. Where a specific orphaned artifact or missing
generator serves a graded requirement, the violation names that ``REQ-*`` id
too -- read from ``docs/rtm.json``, the ONE materialized traceability corpus
this project already maintains (``core/traceability_utils.py save_rtm()``,
the same corpus ``scripts/check_spec_authority.py`` and
``scripts/check_requirement_corpus.py`` read). This gate does not invent a
second notion of "spec" -- an empty result means the corpus does not
currently link anything to that path, not that nothing could.
This check is deliberately **NON-WAIVABLE**. It does not import
``core/gate_waivers.py`` and must never be made to: six other checkers honour
the ``# gate-ok: <reason>`` convention because SOME findings are a judgment
call an author can reasonably override at the line. A missing generator is
not that kind of finding -- it is a fact about the filesystem and git
history, and a loss-detector a line comment can silence is exactly the
mechanism that let 6a186c9d go unnoticed for a month. There is likewise no
``--warn-only``/``--severity``/env-var downgrade path (contrast
``scripts/doc_lint.py --warn-only``, which is warn-only ON PURPOSE for a
different, softer class of finding): this gate always exits non-zero on a
real violation, and treats "could not determine the answer" as a violation
too (see "WHY IT COUNTS WHAT IT EXAMINED" below) -- unknown is not clean.
WHY IT COUNTS WHAT IT EXAMINED
------------------------------
This repo's recurring defect is a checker whose "0 violations" is
indistinguishable from "I never looked" (see the wiki concept page *A checker
must prove it looked*). So this gate refuses to report a pass it cannot
justify: an empty registry, or a registry whose entries all resolve to
nothing on disk, is a FAILURE, not a clean run -- and every run prints the
number of artifacts examined alongside the verdict.
Exit codes: 0 pass · 1 violations found · 2 the gate could not do its job.
"""
from __future__ import annotations
import argparse
import ast
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
#: Matches "Generated by X" / "generated-by: X" and captures only a
#: path-like or dotted-module token -- never a bare English phrase like "the
#: protocol buffer compiler", which has neither a file extension nor a dot
#: joining two identifiers. Prose header claims are (correctly) invisible to
#: the discovery check below: they name an external tool, not something in
#: this repo. Declared at module scope, next to the other module constants,
#: so it compiles once.
_HEADER_GENERATOR_RE = re.compile(
r"generated[- ]by:?\s+"
r"([A-Za-z0-9_./-]+\.(?:py|pyi|sh|pl|j2|proto)"
r"|[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)",
re.IGNORECASE,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
EXIT_PASS = 0
EXIT_VIOLATIONS = 1
EXIT_INVALID = 2
@dataclass(frozen=True)
class Artifact:
"""A generated file that RUL-030 requires git to track.
``generator`` is more than documentation: :func:`check_generators_exist`
resolves it to an on-disk path and fails when the code it names is
gone -- the FORWARD half of the bidirectional-provenance invariant (see
the module docstring's "THE OTHER DIRECTION"). It is free text because
generators genuinely vary in shape (a script invocation, an external
tool like ``uv``, a frozen/non-re-runnable description); the check only
fires on the shapes that name a checkable repo path.
``runtime_input`` marks the artifacts the RUNNING SUITE consumes —
imported, consulted, or installed-from — as opposed to snapshots and
reports that tools read as data behind their own freshness gates.
``scripts/affected_tests.py`` certifies generated artifacts inert
(a change costs only the smoke floor), and that is exactly wrong for
this subset: a ``uv.lock`` change swaps the dependency versions every
test imports, and a ``cxp_pb2.py`` regeneration changes the codec the
CXP tests exercise. Declared HERE, on the one canonical registry,
so the selector derives the split instead of growing a second list.
"""
path: str
generator: str
consumer: str
runtime_input: bool = False
@dataclass(frozen=True)
class Exclusion:
"""A machine-written path that is deliberately NOT an artifact.
Recorded here rather than only in ``.gitignore`` so that the boundary is
stated where the rule is enforced, and so a future reader can challenge
a specific exclusion instead of guessing why something is missing.
"""
path: str
reason: str
#: Every derived snapshot that must be tracked. Seeded from a measured sweep
#: of the generator surfaces (scripts/, .claude/hooks/, src/ writers) rather
#: than from filename guessing.
GENERATED_ARTIFACTS: tuple[Artifact, ...] = (
Artifact(
"plans/ast-ultimate.json",
"scripts/serialize_codebase_ast.py serialize",
"the Prolog gate fact base, code_intel, trace_intel, rts",
),
Artifact(
"plans/ast-tests.json",
# DERIVED from ast-ultimate.json (ast_pipeline step 1b), never parsed
# on its own. This entry used to name `serialize_codebase_ast.py
# serialize --source tests`, which is the abandoned second-parse
# approach: two snapshots of the same files made by different
# invocations drift, and tests/test_orchestrata_ast_derive_subtree.py
# exists to forbid exactly that. The declaration stayed behind after
# the pipeline moved to derive_subtree, and on 2026-08-19 a reader
# followed it, regenerated the artifact the wrong way, and the ship's
# suite caught the disagreement. check_generators_exist could not:
# it resolves the named path and serialize_codebase_ast.py does exist,
# so a wrong-but-present generator passes the forward check.
"scripts/run_ast_pipeline.py --snapshot-only",
"test-side traceability and affected-test selection",
),
Artifact(
"plans/ast-deep-comparison.json",
"scripts/serialize_codebase_ast.py deep-compare",
"port-completion reporting",
),
Artifact(
"docs/CODEBASE_MAP.md",
".claude/hooks/inject-codebase-map.sh",
"@imported into every session and every subagent",
),
Artifact(
"docs/CODEBASE_MAP_FULL.md",
".claude/hooks/inject-codebase-map.sh",
"on-demand symbol lookup (grep + ranged reads); deliberately NOT @imported",
),
Artifact(
"docs/LEVEL0.md",
"scripts/gen_level0.py",
"@imported as the always-in-context foundation tier",
),
Artifact(
"reports/run-tests-latest.json",
"scripts/run_tests.py",
"the session error report; the evidence behind any green claim",
),
Artifact(
"src/orchestrata_ultimate/prolog/intent_lexicon.gen.pl",
"scripts/gen_intent_lexicon.py",
"consulted by the intent pipeline; gated by lexicon-drift",
runtime_input=True,
),
Artifact(
"uv.lock",
"uv",
"reproducible installs across machines and CI",
runtime_input=True,
),
Artifact(
"infra/terraform/.terraform.lock.hcl",
"terraform init",
"terraform, to pin provider versions + hashes",
),
Artifact(
"infra/rehearsal/.terraform.lock.hcl",
"terraform init (rehearsal overlay)",
"terraform, to pin provider versions + hashes",
),
# --- generated CODE ---------------------------------------------------
Artifact(
"src/orchestrata_ultimate/cxp/proto/cxp_pb2.py",
"scripts/gen_cxp.py (protoc)",
"every CXP codec path; imported at runtime",
runtime_input=True,
),
Artifact(
"src/orchestrata_ultimate/cxp/proto/cxp_pb2.pyi",
"scripts/gen_cxp.py (protoc)",
"type checking of the CXP codec",
runtime_input=True,
),
Artifact(
"tests/generated_*/**/*.py",
"scripts/generate_pytest.py",
"the suite runs them -- 268 files the runner drives every time",
runtime_input=True,
),
Artifact(
"plugins/orchestrata/**",
"claude/scripts/gen_plugin.py",
"Claude Code -- the installable plugin, a generated mirror of claude/",
),
Artifact(
".claude-plugin/marketplace.json",
"claude/scripts/gen_plugin.py",
"Claude Code marketplace discovery for the orchestrata plugin",
),
# --- traceability / RTM -----------------------------------------------
Artifact(
"plans/derived-rtm.json",
"scripts/derive_rtm.py build()",
"trace_intel and the RTM acceptance pin",
),
Artifact(
"reports/derived-rtm.md",
"scripts/derive_rtm.py write_report()",
"the human-readable RTM",
),
Artifact(
"docs/rtm.json",
"src/orchestrata_ultimate/core/traceability_utils.py save_rtm()",
"requirement coverage reporting",
),
Artifact(
"docs/rtm.md",
"src/orchestrata_ultimate/core/traceability_utils.py save_rtm()",
"requirement coverage reporting (human tier)",
),
Artifact(
"reports/rtm-evidence.json",
"scripts/generate_rtm_evidence.py",
"evidence-graded RTM rows and their proof class",
),
Artifact(
"reports/rtm-evidence.md",
"scripts/generate_rtm_evidence.py",
"evidence-graded RTM (human tier)",
),
# --- code-intelligence inputs -----------------------------------------
Artifact(
"plans/ast-orchestrata.json",
"scripts/serialize_codebase_ast.py serialize (frozen monorepo)",
"deep-compare's other side; port-completion reporting",
),
Artifact(
"plans/reference-edges.json",
"scripts/extract_reference_edges.py",
"non-import reference edges for the code-intelligence KB",
),
Artifact(
"plans/runtime-callgraph.json",
"scripts/build_runtime_callgraph.py",
"runtime call-graph merged into the AST snapshot",
),
Artifact(
"reports/runtime-callgraph.md",
"scripts/build_runtime_callgraph.py write_report()",
"the human-readable runtime call graph",
),
# --- baselines a gate compares against ---------------------------------
Artifact(
"reports/graph-write-swallow-baseline.json",
"scripts/check_graph_write_swallow.py --update-baseline",
"the graph-write-swallow gate's own baseline",
),
# --- session record ----------------------------------------------------
Artifact(
"context.history.md",
"session CLI (ContextFileManager)",
"the append-only session history a new session reads",
),
# --- secrets CONFIG (not a secret: public recipients only) -------------
Artifact(
".sops.yaml",
"scripts/secrets_cli.py write_sops_config()",
"sops, to pick recipients when encrypting; carries public age "
"recipients only, never a private key",
),
Artifact(
"context.json",
"session CLI (~/.claude/tools/session_cli.py)",
"session state injected at every session start",
),
Artifact(
"context.md",
"session CLI (~/.claude/tools/session_cli.py)",
"the human-readable session log",
),
# --- registered by the RUL-030 follow-up sweep --------------------------
# (backlog 503: tracked, generator-confirmed, previously unprotected.
# docs/diagrams/pkg-*.mmd and environment-*.{dot,png,svg} stay OUT: no
# re-runnable writer names those paths in code, and an entry whose
# generator cannot be re-run is exactly the "registry nobody maintains"
# this module's docstring forbids. plans/class-mapping.json stays out
# too — it is a HAND-CURATED input to deep-compare, not a generated
# artifact, and must never be registered as one.)
Artifact(
"specs/*/checklists/requirements.md",
"scripts/parse_spec_requirements.py",
"per-spec requirement checklists; consumed by spec-review tooling",
),
Artifact(
"plans/spec_term_prominence.json",
"scripts/spec_word_cloud.py",
"spec term-prominence data behind the word-cloud rendering",
),
Artifact(
"docs/diagrams/spec-word-cloud.svg",
"scripts/spec_word_cloud.py",
"the rendered spec word cloud embedded in docs",
),
Artifact(
"plans/semantic_graph_qa_results_latest.md",
"scripts/evaluate_semantic_graph_qa.py",
"the 100-question semantic-graph retrieval benchmark record",
),
Artifact(
"reports/code-intel-slo.json",
"scripts/benchmark_code_intel.py --json",
"Step 3.4 code-intelligence SLO measurements",
),
Artifact(
"reports/prolog-codeintel-benchmark.md",
"scripts/benchmark_prolog_codeintel.py",
"Prolog-vs-Python code-intelligence benchmark record",
),
Artifact(
"rtm/*.json",
"legacy monorepo RTM tooling (frozen; not re-runnable here)",
"historical traceability record, mirrored into the plugin bundle",
),
Artifact(
"rtm/*.md",
"legacy monorepo RTM tooling (frozen; not re-runnable here)",
"historical traceability record (human tier)",
),
)
#: Machine-written paths that fail the "function of the commit" test. Each
#: needs a reason a reviewer can argue with.
NOT_ARTIFACTS: tuple[Exclusion, ...] = (
Exclusion(
"*.warmcache",
"machine-specific pickled AST cache, content-sha1 self-validating and "
"regenerable; identical commits produce different bytes per machine",
),
Exclusion(
"vendor/wheels/*.whl",
"free-threaded tree-sitter wheels built per-host by "
"scripts/build_ft_tree_sitter.py: platform-tagged binaries "
"(cp314t-linux_x86_64), so identical commits produce different "
"bytes per machine — the backlog-503 sweep listed them as tracked, "
"but vendor/wheels/.gitignore deliberately excludes them",
),
Exclusion(
".ast_daemon/",
"live-service scratch seeded from the AST snapshot, never read back",
),
Exclusion(
"reports/test-durations.ndjson",
"append-only per-run telemetry, 282 MB and unbounded -- a function of "
"how many times the suite has run, not of the commit",
),
Exclusion(
"reports/session-logs/",
"per-run logs; wall-clock content, not commit content",
),
Exclusion(
"reports/promotion-state.json",
"promote_env.py's runtime ledger: it records WHEN a promotion ran, so "
"its content is wall-clock state rather than a function of the commit "
"(and it currently carries a known stale-timestamp defect)",
),
Exclusion(
".context.lock",
"runtime cross-process lock",
),
Exclusion(
"backups/",
"graph dumps; large binaries that describe a database, not this commit",
),
# These were excluded ONLY by .gitignore until an exhaustive sweep found
# them sitting outside both registries -- precisely the "buried in a
# .gitignore line nobody reads" failure this file's docstring says it
# exists to prevent. Declaring them here is the fix.
Exclusion(
".cache/",
"gate + tool caches (226 MB): keyed by machine-local input "
"fingerprints, so identical commits produce different bytes per host",
),
Exclusion(
".claude/run/",
"AST-daemon runtime dir, incl. a 75 MB codebase_kb/facts.pl rebuilt "
"from the tracked AST snapshot -- derived FROM an artifact, not one",
),
Exclusion(
".flake_recoveries.ndjson",
"solo-retry flake log: appended per run, so its content is a "
"function of how often the suite ran, not of the commit",
),
Exclusion(
".claude/settings.local.json",
"per-machine local overrides of shared settings, by definition not a "
"function of the commit",
),
Exclusion(
"factory_ledger.jsonl / factory_attestations.jsonl / factory_kb/",
"SPEC-033 build-factory ledgers: append-only wall-clock records of "
"what a run did, written to the CWD of whoever ran it",
),
Exclusion(
"pipeline_checkpoints/ + pipeline_output/",
"per-run pipeline state and products, keyed by run id",
),
Exclusion(
"reviews/",
"per-run review outputs (1,730 files): a function of how many "
"reviews have been run",
),
)
def _expand(artifact: Artifact, repo_root: Path) -> list[tuple[Artifact, str]]:
"""Resolve one registry entry to the concrete paths to check.
A plain path yields itself. A GLOB (any entry containing ``*``) yields
every match, because several generators emit a whole class of files --
``scripts/generate_pytest.py`` alone writes 268 of them under
``tests/generated_*/`` -- and enumerating those by hand would be a
registry nobody maintains.
A glob that matches nothing yields the glob itself as a single unmatched
entry, so it is reported rather than vanishing. That direction matters:
a glob silently expanding to zero paths would let the gate examine
nothing while still printing a pass, which is the defect this whole file
is built to refuse.
"""
if "*" not in artifact.path:
return [(artifact, artifact.path)]
matches = sorted(
str(p.relative_to(repo_root))
for p in repo_root.glob(artifact.path)
if p.is_file()
)
return [(artifact, m) for m in matches] or [(artifact, artifact.path)]
def _git(args: list[str], repo_root: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=repo_root,
capture_output=True,
text=True,
check=False,
)
def _is_ignored(path: str, repo_root: Path) -> str | None:
"""The ``.gitignore`` rule excluding *path*, or ``None``.
``git check-ignore -v`` prints ``<source>:<line>:<pattern>\\t<path>`` when
the path is ignored, which is what makes the violation actionable -- the
fix is to delete that specific line.
"""
result = _git(["check-ignore", "-v", "--no-index", path], repo_root)
if result.returncode != 0:
return None
first = result.stdout.strip().splitlines()
return first[0].split("\t")[0] if first else "(unknown rule)"
def _is_tracked(path: str, repo_root: Path) -> bool:
return _git(["ls-files", "--error-unmatch", path], repo_root).returncode == 0
def check(
artifacts: tuple[Artifact, ...] | None = None,
repo_root: Path | None = None,
) -> tuple[list[str], int]:
"""Return ``(violations, examined)``.
*examined* is returned so the caller can refuse to report a pass it did
not earn -- see the module docstring.
``artifacts``/``repo_root`` are injection seams: the tests drive this
against a REAL temporary git repository with real ``.gitignore`` rules,
rather than patching module state (RUL-002 forbids monkeypatch, and a
gate whose tests patch its internals is not evidence the gate works).
"""
registry = GENERATED_ARTIFACTS if artifacts is None else artifacts
root = REPO_ROOT if repo_root is None else repo_root
violations: list[str] = []
examined = 0
for entry in registry:
for artifact, path in _expand(entry, root):
on_disk = (root / path).exists()
tracked = _is_tracked(path, root)
if not on_disk and not tracked:
# Neither present nor tracked: the generator has never run
# here. Not a RUL-030 violation (nothing is being kept out),
# but it is not evidence of compliance either, so it does not
# count as examined.
continue
examined += 1
rule = _is_ignored(path, root)
if rule is not None:
violations.append(
f"{path}: IGNORED by {rule} — a generated artifact "
f"cannot be excluded from git (RUL-030). Generator: "
f"{artifact.generator}; read by: {artifact.consumer}."
)
continue
if not tracked:
violations.append(
f"{path}: exists but is UNTRACKED — generated and "
f"never added (RUL-030). Generator: {artifact.generator}."
)
return violations, examined
# --- forward check: every registered generator must still exist -----------
def _generator_path_hint(generator: str) -> str | None:
"""The leading repo-relative path in *generator*, if it looks like one.
Registry entries mix three shapes: a script invocation ("scripts/x.py
subcommand"), an external tool ("uv", "terraform init"), and prose
("legacy monorepo RTM tooling (frozen; not re-runnable here)"). Only the
first shape names something this repo can check for -- a bare command or
a sentence has no file to look up, and a ``~``-relative path names
something outside the repo entirely. The heuristic: take the first
whitespace-delimited token, and treat it as checkable only when it
contains a path separator -- a bare word like "uv" or "terraform" never
does, and every observed script-invocation entry does.
"""
tokens = generator.split()
if not tokens:
return None
token = tokens[0]
if token.startswith("~") or "/" not in token:
return None
return token
#: Prefix tags on a provenance violation line, load-bearing for two things:
#: a human skimming `` - `` bullets sees the verdict before the prose, and
#: :func:`main` counts them to build the loud summary banner. LOSS means a
#: matching path was deleted from git history (see :func:`_git_deleted_path`)
#: -- a one-line fix, `git checkout <sha>~1 -- <path>`. NEVER-EXISTED means
#: no such path was ever committed here: the claim itself may be wrong, and
#: that is a different, harder conversation than "restore what we deleted".
#: Conflating the two wastes the reader's time deciding which question to
#: even ask.
_LOSS_MARKER = "[LOSS]"
_NEVER_EXISTED_MARKER = "[NEVER-EXISTED]"
def _generator_path_candidates(token: str) -> tuple[str, ...]:
"""Repo-relative path strings worth trying for *token*.
Shared by :func:`_generator_reference_exists`'s dotted-module branch and
:func:`_git_deleted_path`'s git-history search, so the two "where might
this generator live" guesses can never silently drift apart. Every
observed header/registry token omits the ``scripts/``/``src/`` prefix,
so each known generator home is tried in turn, as a bare filename, as a
package directory, and as a single module file.
"""
if "/" in token:
return (token,)
if "." not in token:
return ()
as_path = "/".join(token.split("."))
candidates: list[str] = []
for base_dir in _GENERATOR_SEARCH_ROOTS:
candidates.append(f"{base_dir}/{token}")
candidates.append(f"{base_dir}/{as_path}")
candidates.append(f"{base_dir}/{as_path}.py")
return tuple(candidates)
def _git_deleted_path(token: str, repo_root: Path) -> tuple[str, str, str] | None:
"""If a path matching *token* was DELETED from git history, return
``(sha, date, path)`` for the commit that deleted it -- proof of LOSS
rather than NEVER-EXISTED (see :data:`_LOSS_MARKER`). Searched with
``--all`` (every branch, not just the current one) and
``--diff-filter=D`` (only commits that removed the path), so a generator
deleted on a branch that never merged still counts as a loss. The first
candidate with any hit wins; ``git log`` lists newest first, so that hit
is the most recent deletion.
"""
for candidate in _generator_path_candidates(token):
result = _git(
[
"log",
"--all",
"--diff-filter=D",
"--format=%H|%ad",
"--date=short",
"--",
candidate,
],
repo_root,
)
if result.returncode != 0 or not result.stdout.strip():
continue
sha, _, date = result.stdout.strip().splitlines()[0].partition("|")
return sha, date, candidate
return None
def _related_requirements(paths: list[str], repo_root: Path) -> list[str]:
"""Best-effort ``REQ-*`` ids that *paths* serve, read from
``docs/rtm.json`` -- the ONE materialized traceability corpus this
project already maintains (``core/traceability_utils.py save_rtm()``,
the same corpus ``scripts/check_spec_authority.py`` and
``scripts/check_requirement_corpus.py`` read from ``@traces``
decorators). Never invents an id: an empty result means the corpus does
not currently link anything to these paths, not that nothing does.
"""
rtm_path = repo_root / "docs" / "rtm.json"
try:
data = json.loads(rtm_path.read_text(encoding="utf-8"))
except OSError, json.JSONDecodeError:
return []
wanted = {str(Path(p)) for p in paths}
req_ids: set[str] = set()
for entry in data.get("entries", ()):
file_path = str(entry.get("file_path", ""))
if any(file_path == w or file_path.endswith(f"/{w}") for w in wanted):
req_id = entry.get("req_id", "")
if req_id and req_id != "INVALID-ID":
req_ids.add(req_id)
return sorted(req_ids)
def _provenance_violation(generator: str, paths: list[str], repo_root: Path) -> str:
"""One loud, actionable line for a generator that does not resolve.
Leads with the LOSS/NEVER-EXISTED tag (see :data:`_LOSS_MARKER`), never
truncates the affected-artifact count silently (a capped sample plus an
explicit "+N more"), names the ``REQ-*`` id it serves when the
traceability corpus knows one, and states the remedy explicitly --
including that retiring an artifact needs the user's approval, because
this gate reports and never deletes anything itself.
"""
count = len(paths)
sample = ", ".join(paths[:3])
more = f" (+{count - 3} more)" if count > 3 else ""
deletion = _git_deleted_path(generator, repo_root)
if deletion is not None:
sha, date, deleted_path = deletion
tag = _LOSS_MARKER
origin = (
f"deleted in commit {sha[:12]} on {date} "
f"(inspect: git show {sha[:12]} -- {deleted_path})"
)
remedy = f"restore it (git checkout {sha}~1 -- {deleted_path})"
else:
tag = _NEVER_EXISTED_MARKER
origin = "no matching path was ever committed to this repository"
remedy = "the header/registry claim naming it may itself be wrong"
reqs = _related_requirements(paths, repo_root)
req_note = f" Serves: {', '.join(reqs)}." if reqs else ""
return (
f"{tag} generator '{generator}' does not exist ({origin}), but "
f"{count} artifact(s) declare it: {sample}{more}.{req_note} Remedy: "
f"{remedy}, OR retire the artifacts that cite it -- retiring "
f"requires the USER'S EXPLICIT APPROVAL; this gate reports, it "
f"never deletes."
)
def check_generators_exist(
artifacts: tuple[Artifact, ...] | None = None,
repo_root: Path | None = None,
) -> tuple[list[str], int]:
"""Return ``(violations, examined)`` for the FORWARD provenance check.
This is the direction 6a186c9d broke: a generator was deleted while its
artifacts were still in the tree, and nothing asserted the generator
itself still existed. For every registry entry with a checkable
generator path, that path must be present on disk -- missing it means
every artifact citing that generator is now orphaned, and the violation
names how many, whether it is a LOSS or a NEVER-EXISTED claim, and the
``REQ-*`` id it serves when the traceability corpus knows one.
``artifacts``/``repo_root`` are the same injection seams :func:`check`
uses, for the same reason: real fixtures under ``tmp_path``, never
patched module state. This check does NOT consult ``core/gate_waivers``
-- see the module docstring's "AUTHORITY" section for why a loss-detector
must not be waivable.
"""
registry = GENERATED_ARTIFACTS if artifacts is None else artifacts
root = REPO_ROOT if repo_root is None else repo_root
violations: list[str] = []
examined = 0
for entry in registry:
hint = _generator_path_hint(entry.generator)
if hint is None:
continue # external tool or prose -- nothing on disk to check
examined += 1
if (root / hint).exists():
continue
expanded = _expand(entry, root)
paths = [path for _, path in expanded]
violations.append(_provenance_violation(hint, paths, root))
return violations, examined
# --- discovery check: headers claiming machine generation ------------------
#: Lines scanned per file. Headers, not fixture data buried in a test body --
#: bounding the read is also what keeps this check cheap on a 1.6 MB tracked
#: artifact like docs/CODEBASE_MAP_FULL.md.
_HEADER_WINDOW = 30
#: File kinds worth reading a header from. Deliberately excludes binaries
#: (``.svg``, ``.gz``, ``.whl``, ...) -- a text-mode ``readline()`` against a
#: compressed blob is both meaningless and, without a newline for a long
#: stretch, needlessly slow.
_SCANNABLE_SUFFIXES = frozenset(
{
".py",
".pyi",
".md",
".sh",
".pl",
".j2",
".proto",
".yaml",
".yml",
".json",
".ts",
".tsx",
".js",
".txt",
".toml",
}
)
#: The known generator homes -- every producer name observed in a header
#: omits this prefix (``ast_serializer.testgen`` really means
#: ``scripts/ast_serializer/testgen``), so resolution tries each in turn.
_GENERATOR_SEARCH_ROOTS = ("scripts", "src", "claude", ".claude")
def _read_header(path: Path, window: int = _HEADER_WINDOW) -> str:
"""The first *window* lines of *path*, decoded best-effort.
Bounded reads only -- ``readline()`` in a loop, never ``.read()`` or
``.readlines()``, so a multi-MB tracked artifact costs a few dozen
lines, not the whole file.
"""
lines: list[str] = []
try:
with path.open("r", encoding="utf-8", errors="ignore") as handle:
for _ in range(window):
line = handle.readline()
if not line:
break
lines.append(line)
except OSError:
return ""
return "".join(lines)
def _extract_header_generator(head: str) -> str | None:
"""The producer named by a header's "Generated by X" claim, or ``None``
if the header makes no such claim (or only a prose one -- see
:data:`_HEADER_GENERATOR_RE`)."""
match = _HEADER_GENERATOR_RE.search(head)
return match.group(1) if match else None
def _generator_reference_exists(token: str, repo_root: Path) -> bool:
"""Resolve a header-claimed generator name to real source on disk.
Headers name a generator three ways, observed across the corpus: a
literal relative path, a bare filename with no directory
(``generate_pytest.py``), or dotted-module notation with no file
extension (``ast_serializer.testgen``). All three are tried against the
known generator homes (:data:`_GENERATOR_SEARCH_ROOTS`), because every
observed header omits that prefix.
A directory candidate only counts as existing when it holds at least one
real ``.py`` file. An empty package shell -- a stale ``__pycache__/``
left behind by a deleted generator, directory present but no source --
is NOT existence. That distinction is exactly what 6a186c9d's "zero
consumers" audit missed, and exactly what would make this check a false
pass if skipped.
"""
if (repo_root / token).exists():
return True
if "/" in token or "." not in token:
return False # no relative-path shape left worth trying
# Bare filename with an extension, e.g. "generate_pytest.py".
for base_dir in _GENERATOR_SEARCH_ROOTS:
search_root = repo_root / base_dir
if search_root.is_dir() and any(search_root.rglob(token)):
return True
# Dotted-module notation, e.g. "ast_serializer.testgen".
as_path = Path(*token.split("."))
for base_dir in _GENERATOR_SEARCH_ROOTS:
base = repo_root / base_dir / as_path
if base.with_suffix(".py").exists():
return True
if base.is_dir() and any(base.rglob("*.py")):
return True
return False
def scan_generator_headers(
repo_root: Path | None = None,
source_dirs: tuple[str, ...] = ("tests", "src", "docs"),
) -> dict[str, list[str]]:
"""Map every header-claimed generator to the files that name it.
This is the DISCOVERY half of the provenance check -- the one that
would actually have caught 6a186c9d, because it does not trust the
registry at all. It reads the artifacts' own headers instead: a file
that says "Generated by ast_serializer.testgen" makes that claim
regardless of whether anyone remembered to register it in
:data:`GENERATED_ARTIFACTS`.
"""
root = REPO_ROOT if repo_root is None else repo_root
found: dict[str, list[str]] = {}
for source_dir in source_dirs:
base = root / source_dir
if not base.is_dir():
continue
for path in sorted(base.rglob("*")):
if not path.is_file() or path.suffix not in _SCANNABLE_SUFFIXES:
continue
generator = _extract_header_generator(_read_header(path))
if generator is None:
continue
found.setdefault(generator, []).append(str(path.relative_to(root)))
return found
def check_generator_headers(
repo_root: Path | None = None,
source_dirs: tuple[str, ...] = ("tests", "src", "docs"),
) -> tuple[list[str], int]:
"""Return ``(violations, examined)`` for the header-DISCOVERY check.
``examined`` counts distinct producer NAMES discovered, not files -- one
deleted generator can be declared by a hundred files, and the violation
message already states that count; counting every file as its own
"examination" would only inflate the number without adding information.
Like :func:`check_generators_exist`, this does NOT consult
``core/gate_waivers`` -- non-waivable by design, see the module
docstring's "AUTHORITY" section.
"""
root = REPO_ROOT if repo_root is None else repo_root
found = scan_generator_headers(root, source_dirs)
violations: list[str] = []
for generator, paths in sorted(found.items()):
if _generator_reference_exists(generator, root):
continue
violations.append(_provenance_violation(generator, paths, root))
return violations, len(found)
#: Sinks that PUBLISH a file by replacing it. ``os.replace`` gives the
#: destination a NEW inode, so a reader already holding the old one keeps
#: reading a complete file instead of faulting.
_ATOMIC_SINKS = frozenset(
{
"dump_json_atomic",
"dump_text_atomic",
"_atomic_write_json",
"_atomic_write_text",
"_atomic_write",
"replace",
}
)
#: Calls that truncate the destination IN PLACE, keeping its inode.
_TRUNCATING_METHODS = frozenset({"write_text", "write_bytes"})
def _truncating_writes(source: str, basename: str) -> list[int]:
"""Line numbers where *source* truncates its declared artifact in place.
A write counts when the DESTINATION expression either names the artifact
literally (``ROOT / "docs" / "CODEBASE_MAP.md"``) or is one of the
conventional output holders in :data:`_OUTPUT_NAMES` (``args.output``).
LIMITATION, stated because a silent gap reads as a pass: a destination
reached through neither route -- a path stored in a dict, or computed in a
helper and returned -- is NOT seen. This check is deliberately quiet
rather than noisy; it is a floor on the obvious shapes, not a proof of
absence. The same posture ``check_generators_exist`` takes with generator
strings it cannot resolve.
"""
try:
tree = ast.parse(source)
except SyntaxError:
return []
# Names that PROVABLY hold this artifact's path, resolved from the script
# rather than guessed from the identifier. Guessing was tried and was
# wrong: accepting any ``args.output`` produced 36 false positives in one
# run, because `serialize_codebase_ast.py` writes ``args.output`` from a
# dozen code-GENERATION subcommands (gen-dto, gen-serde, diagram, ...)
# that have nothing to do with the AST snapshot, and it is the registered
# generator for three artifacts, so each write was attributed to all
# three. A matcher that reads an argument NAME as intent repeats the
# defect this gate exists to catch elsewhere.
holders: set[str] = set()
constants: dict[str, str] = {}
def _as_path_string(node: ast.AST | None) -> str | None:
"""A literal path, whether bare or wrapped in ``Path(...)``.
Generators declare defaults both ways (``default="reports/x.json"``
and ``default=Path("reports/x.json")``); reading only the bare form
left three of five known writers unresolved.
"""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return constants.get(node.id)
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id in {"Path", "PurePath"}
and node.args
):
return _as_path_string(node.args[0])
# ``ROOT / "plans" / "rtm.json"`` — only the RIGHT operand matters,
# since the basename is what identifies the artifact.
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
return _as_path_string(node.right)
return None
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
value = _as_path_string(node.value)
if value:
for tgt in node.targets:
if isinstance(tgt, ast.Name):
constants[tgt.id] = value
for name, value in constants.items():
if Path(value).name == basename:
holders.add(name)
# argparse: --json-out default=DEFAULT_JSON_OUT -> args.json_out
for node in ast.walk(tree):
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "add_argument"
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
):
continue
flag = node.args[0].value
if not flag.startswith("--"):
continue
default = next((k.value for k in node.keywords if k.arg == "default"), None)
resolved = _as_path_string(default)
if not (resolved and Path(resolved).name == basename):
continue
# An explicit ``dest=`` WINS over the flag spelling. Deriving the name
# from the flag alone missed `add_argument("--json", dest="json_out",
# default=DEFAULT_JSON_OUT)` -- it bound "json", while the code writes
# `args.json_out`.
explicit = next((k.value for k in node.keywords if k.arg == "dest"), None)
if isinstance(explicit, ast.Constant) and isinstance(explicit.value, str):
holders.add(explicit.value)
else:
holders.add(flag[2:].replace("-", "_"))
# Function parameters whose DEFAULT is the artifact: `derive_rtm.build`
# takes `out_json: Path = DEFAULT_OUT_JSON` and writes to that name.
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
continue
spec = node.args
positional = spec.posonlyargs + spec.args
pairs = list(
zip(
positional[len(positional) - len(spec.defaults) :],
spec.defaults,
strict=False,
)
)
pairs += [
(a, d) for a, d in zip(spec.kwonlyargs, spec.kw_defaults, strict=False) if d
]
for arg, default_node in pairs:
value = _as_path_string(default_node)
if value and Path(value).name == basename:
holders.add(arg.arg)
def _names_the_artifact(dest: str) -> bool:
if basename in dest:
return True
return any(re.search(rf"\b{re.escape(h)}\b", dest) for h in holders)
hits: list[int] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
dest: str | None = None
func = node.func
if isinstance(func, ast.Attribute) and func.attr in _TRUNCATING_METHODS:
dest = ast.unparse(func.value)
elif isinstance(func, ast.Name) and func.id == "open" and len(node.args) > 1:
mode = node.args[1]
if (
isinstance(mode, ast.Constant)
and isinstance(mode.value, str)
and mode.value[:1] in {"w", "a"}
):
dest = ast.unparse(node.args[0])
if dest and _names_the_artifact(dest):
hits.append(node.lineno)
return hits
def check_atomic_writes(
repo_root: Path | None = None,
artifacts: tuple[Artifact, ...] | None = None,
) -> tuple[list[str], int]:
"""Return ``(violations, examined)`` — no generator may truncate in place.
RUL-030 already says a generated artifact belongs in git. This adds the
property that makes it SAFE to regenerate one while the tree is live: the
write must replace the destination, never truncate it.
``Path.write_text`` and ``open(p, "w")`` truncate at the call and keep the
inode. A reader that already mmap'd that inode -- ``git update-index``
hashing the file to stage it -- then faults on a page past the new end and
is killed with ``SIGBUS``. Measured 2026-08-19: two ``git update-index``
cores 28 minutes apart, one inside ``zng_deflate``; hardware excluded on
four counters (NVMe media errors 0, btrfs corruption/csum/generation 0,
scrub clean over 344 GiB, SMART PASSED). Reproduced deterministically in
``tests/core/test_atomic_json.py`` -- truncating write gives the reader
``-SIGBUS`` and REUSES the inode; ``dump_text_atomic`` gives it ``0`` and a
new one.
This gate is why the fix stays fixed: the generators were corrected once
on 2026-08-21, and nothing but a mechanism stops the next one regressing.
"""
root = repo_root if repo_root is not None else REPO_ROOT
registry = artifacts if artifacts is not None else GENERATED_ARTIFACTS
violations: list[str] = []
examined = 0
for artifact in registry:
hint = _generator_path_hint(artifact.generator)
if hint is None:
continue
script = next(
(
root / candidate
for candidate in _generator_path_candidates(hint)
if (root / candidate).is_file()
),
None,
)
if script is None:
continue
examined += 1
lines = _truncating_writes(
script.read_text(encoding="utf-8", errors="replace"),
Path(artifact.path).name,
)
for line in lines:
violations.append(
f"{script.relative_to(root)}:{line} truncates "
f"{artifact.path} in place — a reader holding it mmap'd dies "
f"with SIGBUS. Publish it with one of "
f"{', '.join(sorted(_ATOMIC_SINKS))} instead."
)
return violations, examined
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="RUL-030: generated artifacts belong in git. "
"Non-waivable: no --warn-only, no # gate-ok: override."
)
parser.add_argument("--json", action="store_true", help="machine-readable output")
args = parser.parse_args(argv)
if not GENERATED_ARTIFACTS:
print(
"[generated-artifacts] INVALID — the registry is empty, so a pass "
"would mean nothing was checked",
file=sys.stderr,
)
return EXIT_INVALID
violations, examined = check()
gen_violations, gen_examined = check_generators_exist()
header_violations, header_examined = check_generator_headers()
atomic_violations, atomic_examined = check_atomic_writes()
all_violations = violations + gen_violations + header_violations + atomic_violations
provenance_violations = gen_violations + header_violations
if args.json:
print(
json.dumps(
{
"examined": examined,
"registered": len(GENERATED_ARTIFACTS),
"excluded": len(NOT_ARTIFACTS),
"violations": violations,
"generators_examined": gen_examined,
"generator_violations": gen_violations,
"header_generators_examined": header_examined,
"header_generator_violations": header_violations,
"atomic_generators_examined": atomic_examined,
"atomic_write_violations": atomic_violations,
"loss_count": sum(
1 for v in provenance_violations if v.startswith(_LOSS_MARKER)
),
"never_existed_count": sum(
1
for v in provenance_violations
if v.startswith(_NEVER_EXISTED_MARKER)
),
},
indent=2,
)
)
# "Could not determine the answer" is a FAIL, never a quiet pass -- an
# empty registry (checked above), a registry that examined nothing, or a
# provenance check that found no checkable generator and no header claim
# ANYWHERE under tests/src/docs are each, on their own terms, "the gate
# did not do its job" rather than "nothing is wrong".
if examined == 0:
print(
f"[generated-artifacts] INVALID — {len(GENERATED_ARTIFACTS)} "
"artifact(s) registered but none present or tracked; a PASS here "
"would report 'nothing wrong' when the truth is 'nothing looked at'",
file=sys.stderr,
)
return EXIT_INVALID
if gen_examined == 0 and header_examined == 0:
print(
"[generated-artifacts] INVALID — the generator-provenance checks "
"examined nothing: no registry entry named a checkable generator "
"path, and no header claiming machine generation was found under "
"tests/, src/, or docs/. That is 'nothing looked at', not "
"'nothing wrong'.",
file=sys.stderr,
)
return EXIT_INVALID
if all_violations:
if not args.json:
loss_n = sum(1 for v in provenance_violations if v.startswith(_LOSS_MARKER))
never_n = sum(
1 for v in provenance_violations if v.startswith(_NEVER_EXISTED_MARKER)
)
if provenance_violations:
print("=" * 72, file=sys.stderr)
print(
f"GENERATOR PROVENANCE LOSS — {loss_n} generator(s) "
f"CONFIRMED DELETED from git history, {never_n} "
"generator(s) NEVER EXISTED in this repository",
file=sys.stderr,
)
print("=" * 72, file=sys.stderr)
print(
f"[generated-artifacts] FAIL — {len(all_violations)} "
f"violation(s) across {examined} tracked artifact(s), "
f"{gen_examined} registry generator(s), and {header_examined} "
"header-claimed generator(s) examined:",
file=sys.stderr,
)
for violation in all_violations:
print(f" - {violation}", file=sys.stderr)
print(
"\nFix: delete the .gitignore line, `git add` the artifact, "
"restore a LOST generator from the commit named above, or "
"retire the artifacts that cite it -- retiring requires the "
"USER'S EXPLICIT APPROVAL; this gate never deletes anything "
"itself. If a tracked path genuinely is not a function of "
"the commit, add it to NOT_ARTIFACTS in this file WITH A "
"REASON. This gate is non-waivable: no # gate-ok:, no "
"--warn-only.",
file=sys.stderr,
)
return EXIT_VIOLATIONS
if not args.json:
print(
"[generated-artifacts] OK — RUL-030 and generator provenance "
f"satisfied: {examined} tracked artifact(s), {gen_examined} "
f"registry generator(s), and {header_examined} header-claimed "
f"generator(s) all check out; {len(NOT_ARTIFACTS)} declared "
"non-artifact(s) excluded by reason"
)
return EXIT_PASS
if __name__ == "__main__":
raise SystemExit(main())