Tree guard
Spec tier: buildable Serves: Standard-bearer Staszek - the tree he adopts from must be complete and free of this repo's internals; Coding agent runs both tools as gates. Status: live Success metric: Retention - a client updating to a new version gets a tree that is complete and verifies green.
Purpose
Guard the single authored shipped tree (standard/, ADR-014): nothing repo-own leaks in, everything the manifest promises is present, every shipped file is a manifest entry, no entry claims a release that has never shipped, workflow pins stay exact, derived facts stay derived, the released version is described where releases are described, and the tree passes its own verifier. Plus: no link anywhere in the repo is dead - neither a relative markdown link nor a by-reference link into this repo's own main branch.
Clarifications
Session 2026-08-04
Retrofitted spec: this capability was built before its spec existed, so there is no clarify session to record. Every contract here was read off the shipped implementation and the decisions it cites, and the questions were settled by what already ran rather than by asking. Written down because the status is now checked against this section, and a live capability with no record of what settled it is the gap that check exists to expose. New work on this capability goes through the loop.
Scope
tools/tree-check.mjs (tree integrity) and tools/link-check.mjs (link integrity). Repo-own tooling - never shipped.
Out of scope
Verifying an adopted repo (verify-engine); checking the rendered web surfaces (web-surface).
Core concepts
- The tree -
standard/; files sit at their real client paths, there is no second source. - Recorded hash - the
sha256acopyentry carries in the manifest: generated from the tree bytools/manifest-hashes.mjs, asserted here, read byself-verifyin an adopted repo. The manifest itself is deliberately unhashed - a file cannot contain its own hash, and an adopter's copy legitimately differs byprofileandexceptions. - Leak - repo-own material (this repo's ADRs, transition skills, retired engine layouts) inside the tree.
- Manifest promise - a
standard/standard.manifest.jsonentry a client expects at its path. - Client-only file - a manifest path the client authors from nothing; absent from the tree by design.
Data contracts
This capability owns and persists nothing: both tools read the repo and print. Nothing here is written, so there is no migration, no idempotency key and no correlation id to state.
What it reads, and where each shape is defined:
| Input | Format | Whose shape it is |
|---|---|---|
standard/standard.manifest.json | JSON | verify-engine's - it is that engine's input, read here only to check the tree against it. The fields this capability touches are files[].path, files[].altPaths, files[].purpose, files[].adapt, files[].sha256, files[].since, sections[].file, sections[].heading and references[].path; no other field is read, and none is written. |
VERSION | text, one line | this repo's own: x.y.z, the version the standard currently ships. |
standard/.nvmrc | text, one line | Node's: an exact x.y.z, no range and no bare major. |
.github/workflows/*.yml, standard/.github/workflows/*.yml | YAML | GitHub Actions'. Read line-wise rather than parsed - only uses:, runs-on: and node-version: are inspected, for pin exactness (R21). |
every tracked and untracked *.md, and every other non-binary file git knows about | text | none - link targets, by-reference URLs and byte content are read as text. |
The recorded content hashes are the one shape this capability asserts rather than borrows: a copy entry's sha256 is SHA-256 over the file's text with CRLF normalized to LF, hex, lower case - a string for a file entry and a { member: hash } map for a directory entry. It is generated by tools/manifest-hashes.mjs and only asserted here, which is the point: a hand-written hash describes a file nobody checked.
Interface contracts
node tools/tree-check.mjs - run from the repo root, no flags, no dependencies. Every check below always runs (the count is deliberately not restated here - it has been wrong three different ways in this file alone, which is the drift the guard's own check 4b exists to stop):
- Leaks. Walk every file under
standard/. A path matching any of these patterns fails:/\/ADR-\d{3}-/- a numbered ADR (this repo's decisions live indocs/decision-records/, clients get them by reference - ADR-004)/\.specify\//- the retired.specifyengine layout (ADR-015)/spec-kit\//- a vendored spec-kit area (ADR-015)/skills\/(align-to-standards|onboard-repo|modernize|greenfield-start|speckit-)/- a transition or speckit skill, never shipped (ADR-009/ADR-015)
- Manifest promises. For every
manifest.files[]entry whosepathis not inCLIENT_ONLY = { ".standards-version", "specs/capability-map.json", "docs/facts.json" }, at least one of[path, ...altPaths]must exist understandard/. Everymanifest.sections[]entry'sfilemust also exist there.CLIENT_ONLYholds the entries a client authors from nothing: shipping an example would seed a repo with another repo's alignment record, capability map or facts.
2b. References resolve. Every manifest.references[] path must exist at this repo's root (the method docs clients adopt by reference, ADR-023) - a dead reference FAILs. 2c. Tree -> manifest coverage. The reverse direction: every file under standard/ must be covered by a manifest entry - an exact path/altPaths match or a prefix match under a directory entry - or listed in the explicit EXEMPT set (empty today; an exemption needs a recorded reason). A shipped-but-unlisted file FAILs: self-verify cannot see it and update-to-latest's delta cannot carry it.
- Skeleton self-verify.
node scripts/self-verify.mjs --skeletonexecuted with cwdstandard/must exit 0; on failure its captured output is indented under the FAIL line.
3b. Recorded content hashes are the tree's own. node tools/manifest-hashes.mjs --check must exit 0: every copy-class entry carries a sha256 matching what the tree ships, and no other entry carries one. The hashes are generated (node tools/manifest-hashes.mjs), never hand-written, and they are what lets an adopted repo detect a file whose content stopped being the standard's - so a stale one is a lie shipped to every aligned repo. The skeleton run above proves the same thing for the entries it reaches; only this check distinguishes no hash recorded from hash recorded and matching, and an unhashed copy entry is silently unverified downstream.
- Version surface.
VERSIONis read;standard/SPEC.mdmust containVersion <V>. The README is deliberately not checked for@<V>: that assertion required the quick start to instruct an adopter to pin a version, which is the model ADR-025 removed. A guard that demands phrasing a decision deleted keeps reinstating it, and this one did - it failed the moment the README was corrected.
4b. Derived facts stay derived. The surfaces in FACT_SURFACES (README.md, llms.txt, AGENTS.md, docs/ecosystem.md, site/index.html, standard/README.md) must not hand-write a rule range (R1-R<n>) or a rule count (<number|word> [numbered] rules); a match FAILs, quoting the offending text. Facts derivable from SPEC.md are stated as "the numbered rules" or derived, never restated by hand. Markup is stripped (<[^>]+> -> a space) before matching: one of the surfaces is HTML, and a count split across a tag - 20<small>rules - reads as a count to a human and as two unrelated tokens to a naive regex, which is exactly how a stale number survived on the landing page while this check reported green. 4c. The released version has a changelog entry. CHANGELOG.md at the repo root must contain a heading ## <V> (an optional [V] bracket allowed, and the character after the version must not be a digit, or 1.0.1 would be satisfied by ## 1.0.13). R18 makes a release one act - promote ## Unreleased into a version section, then bump VERSION, now something the PR itself does by default - and nothing checked the first half of it: thirteen bumps between the 1.0.0 and 1.0.13 releases shipped with no entry at all, 32 commits found by reading rather than by a gate. The manifest already requires the file and its ## Unreleased heading to exist, which is not the same claim: a repo can hold both and still release into silence. The historical gap is not backfilled - CHANGELOG.md records it as a gap, with the commit range to read - because reconstructing twelve entries from commit subjects and a nineteen-commit span nobody split at the time would be a guess in the file whose job is to be checkable. 4d. No entry claims a version that has never shipped. Every object in every array of standard/standard.manifest.json must carry a since that is either the literal unreleased or an x.y.z release not ahead of VERSION; anything else FAILs, naming the array, the entry and the reason. Comparison is numeric part by part, because as strings 1.0.9 sorts after 1.0.13 and the ahead-of-VERSION case this exists for would read as fine. The value is what makes an entry traceable to the release that first shipped it, so a number ahead of VERSION cannot match any real commit and makes the manifest assert a release nobody has actually cut yet - a PR bumps the version itself by default now, but only the PR that lands the bump gets to name it (R18). Found live: an entry declaring since: "1.0.14" against VERSION 1.0.13, added inside an unrelated fix, while every other not-yet-released entry in the same file used unreleased. 4e. The rule has its own cases. node tools/tree-check.mjs --self runs them and exits without reading the tree: unreleased, the current version, an older release and an older patch that sorts later as a string must pass; the next uncut patch, a future minor or major, a two-part version, a release candidate, an unrecognised word and a missing value must fail. It is a separate invocation because every other check in the file runs at module top level, so there is nothing to import without running all of it.
- Workflow pins are exact (R21/ADR-017). Every workflow under
.github/workflows/andstandard/.github/workflows/: eachuses:names a full 40-hex commit SHA (local./actions exempt; comment-only lines - first non-space char#- are skipped), noruns-onlabel containing-latest, no bare-majornode-version, nonode-version-file:at all, andstandard/.nvmrc(when present) is an exactx.y.z.node-version-fileis refused rather than resolved because the rule is that a workflow states its pin: deferring it to a file made the shipped, requiredspec-guard.ymldepend on the optional.nvmrcentry, so the required gate read a file no repo is required to carry - and the pin check never saw it, because it matched onlynode-version:.
Output: one FAIL <message> line per problem, ok <message> per clean check, then the verdict: tree-check: OK - one tree, shippable or tree-check: FAIL - <n> problem(s).
node tools/link-check.mjs - checks every tracked file from git ls-files '*.md' plus every untracked one from git ls-files --others --exclude-standard '*.md' (paths deleted mid-change are skipped). Untracked files are in scope so a fresh doc fails locally before git add, not only in CI where everything is tracked. Each markdown link target matched by \]\(([^)#\s]+?)(?:#[^)]*)?\) must exist when resolved against the linking file's directory. Skip rules:
- any line containing
{{(template placeholder lines describe the client repo, not this one), - targets starting with
https?:,mailto:, or#(absolute, mail, pure anchor), - anything inside backticks - inline code is neutralized before matching (prose about a link is never a checked link).
By-reference links. The same run also checks every link into this repo's own main branch - https://github.com/repository-standards/core/blob|tree/main/<path> - against the local tree, over every tracked and untracked text file (binary extensions skipped), not only markdown. This is the shipped tree's half of link integrity: method docs are adopted by reference (ADR-023), so the tree points at them by full URL, and a URL naming a path that is not here is a dead promise made to every adopted repo - invisible to the relative check by construction. It is scanned outside markdown because the instances found by hand were in a shell script's denial message and a JSON example as often as in prose. An anchor and trailing sentence punctuation are stripped from the path before it is resolved (.../prerequisites.md. at the end of a sentence names a file), and a URL whose path carries a <placeholder> is the form written out rather than a link, so it is skipped.
Failure format: FAIL <file>:<line> -> <target> (1-based line), with the by-reference ones carrying a trailing note naming the rule, then link-check: FAIL - <n> dead relative link(s), <n> by-reference link(s) naming a path that is not here, or both; a clean run prints link-check: OK - all relative links resolve (<n> md files), with , <u> untracked appended when untracked files were scanned, and the count of by-reference links that resolved.
Exit codes
| Tool | Exit | Condition |
|---|---|---|
| tree-check | 0 | every check clean |
| tree-check | 1 | any check above failing: a leak, an unmet manifest promise, an unresolved reference, a shipped file no manifest entry covers, a skeleton self-verify failure, a stale or missing recorded hash, a spec version mismatch, a released version with no changelog entry, a since naming a release that has never shipped, a hand-written derived fact, or a loose workflow pin (count in the verdict) |
| tree-check --self | 0 / 1 | the since rule's own cases all behave / any of them does not |
| link-check | 0 | every relative link resolves, and every by-reference link resolves in this repo |
| link-check | 1 | one or more dead relative links, or by-reference links naming a path that is not here (counts in the verdict) |
Requirements
- Both tools MUST be dependency-free (Node built-ins only) and runnable from the repo root.
- tree-check MUST run every check and report every failure, never stop at the first.
- link-check MUST strip a
#fragmentfrom the target before resolving the path. - tree-check MUST verify that every tracked text file is still diffable - free of NUL bytes. A file carrying one is classified binary by git, so
git diffshows nothing andgrepfinds nothing in it: the file keeps working while silently ceasing to be reviewable, which no other gate detects. Files that are binary by extension are excluded before being read.
Invariants
- A file matching a leak pattern MUST NOT exist under
standard/. - A manifest
files[]path outsideCLIENT_ONLYMUST exist in the tree atpathor at analtPathsentry. - The pristine tree MUST pass its own
self-verify --skeleton. - Every
copy-class manifest entry MUST carry a hash of what the tree actually ships, and no hash may be authored by hand. - No tracked text file MUST contain a NUL byte.
- The version in
VERSIONMUST have a## <version>section in the rootCHANGELOG.md- a released number with nothing describing it is a release nobody can read.
Acceptance criteria
- Leak. GIVEN
standard/docs/decision-records/ADR-001-x.mdexists WHEN tree-check runs THEN a FAIL names the file and the reason, and exit code is 1. - Client-only pass. GIVEN the manifest promises
.standards-versionand the tree does not contain it WHEN tree-check runs THEN the promises check still passes. - Missing promise. GIVEN a manifest file exists at neither
pathnor anyaltPathsunderstandard/WHEN tree-check runs THEN a FAIL namesstandard/<path>and the entry'spurpose, exit 1. - Stale hash. GIVEN a shipped
copyfile is edited and the manifest is not regenerated WHEN tree-check runs THEN a FAIL names the entry and says to runtools/manifest-hashes.mjs, exit 1. - Unreviewable file. GIVEN a tracked text file containing a NUL byte WHEN tree-check runs THEN a FAIL names the file and the line, and exit code is 1.
- Hash on the wrong class. GIVEN a
mergeorfill-from-repoentry carries asha256WHEN the hash check runs THEN it FAILs - only copy-class content is the standard's to fix. - Broken skeleton. GIVEN
self-verify --skeletonexits non-zero insidestandard/WHEN tree-check runs THEN its output appears indented under a FAIL and tree-check exits 1. - Dead link. GIVEN
docs/a.mdline 7 links a relative targetmissing.mdanddocs/missing.mddoes not exist WHEN link-check runs THEN it printsdocs/a.md:7 -> missing.mdand exits 1. (The literal pattern is not reproduced here - it would fail this very check.) - Placeholder skip. GIVEN a line contains
{{project}}and a dead relative link WHEN link-check runs THEN the line is skipped and does not fail. - Anchor skip. GIVEN a link target
#sectionormailto:x@y.zWHEN link-check runs THEN it is ignored. - Untracked dead link. GIVEN a freshly created, not yet
git add-ed md file links a relative target that does not exist WHEN link-check runs THEN it FAILs exactly as for a tracked file, exit 1. - Version mismatch. GIVEN
VERSIONis9.9.9and SPEC.md saysVersion 0.7.2WHEN tree-check runs THEN the SPEC mismatch is reported and exit is 1. - A released version with no entry. GIVEN
VERSIONis1.0.7andCHANGELOG.mdhas headings for1.0.13and1.0.0only WHEN tree-check runs THEN a FAIL says1.0.7has no heading and names R18, and exit is 1 -## 1.0.13does not satisfy1.0.7despite sharing its digits. - The gap note is not an entry. GIVEN
CHANGELOG.mdcarries the recorded-gap heading naming 1.1.0, 1.1.1 and 1.0.2 - 1.0.12 WHENVERSIONreads1.1.0THEN the check still FAILs: the note says those versions have no entry, and a heading that begins with prose cannot be mistaken for the section that would. - The README is not required to name a version. GIVEN a README whose quick start contains no
@<version>at all WHEN tree-check runs THEN no version failure is raised - latest is the only target, so a quick start naming one would be the defect. - Unmanifested file. GIVEN
standard/docs/stray.mdexists and no manifest entry orEXEMPTrow covers it WHEN tree-check runs THEN a FAIL names it and exit is 1. - Floating pin. GIVEN a workflow line
uses: actions/checkout@v4(not a 40-hex SHA) WHEN tree-check runs THEN a FAIL quotes it; a commented-out# uses: ...@v4line is skipped. - Deferred node pin. GIVEN a workflow line
node-version-file: ".nvmrc"WHEN tree-check runs THEN a FAIL quotes it and exit is 1 - the version belongs in the workflow, and reading it from an optional file is how a required gate came to depend on one. - Hand-written count. GIVEN a
FACT_SURFACESfile contains a hand-written rule range WHEN tree-check runs THEN a FAIL quotes the match and exit is 1. - A count hidden by markup is still a count. GIVEN
site/index.htmlcontains<div class="bignum">20<small>rules</small></div>WHEN tree-check runs THEN it FAILs quoting20 rules- the tag between the digits and the word does not exempt it. - Inline-code skip. GIVEN a line quotes a dead target inside backticks WHEN link-check runs THEN the line does not fail.
Open questions
None known.
