repositoryStandards v1.1.20

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 sha256 a copy entry carries in the manifest: generated from the tree by tools/manifest-hashes.mjs, asserted here, read by self-verify in an adopted repo. The manifest itself is deliberately unhashed - a file cannot contain its own hash, and an adopter's copy legitimately differs by profile and exceptions.
  • Leak - repo-own material (this repo's ADRs, transition skills, retired engine layouts) inside the tree.
  • Manifest promise - a standard/standard.manifest.json entry 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:

InputFormatWhose shape it is
standard/standard.manifest.jsonJSONverify-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.
VERSIONtext, one linethis repo's own: x.y.z, the version the standard currently ships.
standard/.nvmrctext, one lineNode's: an exact x.y.z, no range and no bare major.
.github/workflows/*.yml, standard/.github/workflows/*.ymlYAMLGitHub 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 abouttextnone - 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):

  1. 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 in docs/decision-records/, clients get them by reference - ADR-004)
    • /\.specify\// - the retired .specify engine 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)
  2. Manifest promises. For every manifest.files[] entry whose path is not in CLIENT_ONLY = { ".standards-version", "specs/capability-map.json", "docs/facts.json" }, at least one of [path, ...altPaths] must exist under standard/. Every manifest.sections[] entry's file must also exist there. CLIENT_ONLY holds 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.

  1. Skeleton self-verify. node scripts/self-verify.mjs --skeleton executed with cwd standard/ 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.

  1. Version surface. VERSION is read; standard/SPEC.md must contain Version <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.

  1. Workflow pins are exact (R21/ADR-017). Every workflow under .github/workflows/ and standard/.github/workflows/: each uses: names a full 40-hex commit SHA (local ./ actions exempt; comment-only lines - first non-space char # - are skipped), no runs-on label containing -latest, no bare-major node-version, no node-version-file: at all, and standard/.nvmrc (when present) is an exact x.y.z. node-version-file is refused rather than resolved because the rule is that a workflow states its pin: deferring it to a file made the shipped, required spec-guard.yml depend on the optional .nvmrc entry, so the required gate read a file no repo is required to carry - and the pin check never saw it, because it matched only node-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

ToolExitCondition
tree-check0every check clean
tree-check1any 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 --self0 / 1the since rule's own cases all behave / any of them does not
link-check0every relative link resolves, and every by-reference link resolves in this repo
link-check1one 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 #fragment from 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 diff shows nothing and grep finds 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 outside CLIENT_ONLY MUST exist in the tree at path or at an altPaths entry.
  • 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 VERSION MUST have a ## <version> section in the root CHANGELOG.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.md exists 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-version and 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 path nor any altPaths under standard/ WHEN tree-check runs THEN a FAIL names standard/<path> and the entry's purpose, exit 1.
  • Stale hash. GIVEN a shipped copy file is edited and the manifest is not regenerated WHEN tree-check runs THEN a FAIL names the entry and says to run tools/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 merge or fill-from-repo entry carries a sha256 WHEN the hash check runs THEN it FAILs - only copy-class content is the standard's to fix.
  • Broken skeleton. GIVEN self-verify --skeleton exits non-zero inside standard/ WHEN tree-check runs THEN its output appears indented under a FAIL and tree-check exits 1.
  • Dead link. GIVEN docs/a.md line 7 links a relative target missing.md and docs/missing.md does not exist WHEN link-check runs THEN it prints docs/a.md:7 -> missing.md and 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 #section or mailto:x@y.z WHEN 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 VERSION is 9.9.9 and SPEC.md says Version 0.7.2 WHEN tree-check runs THEN the SPEC mismatch is reported and exit is 1.
  • A released version with no entry. GIVEN VERSION is 1.0.7 and CHANGELOG.md has headings for 1.0.13 and 1.0.0 only WHEN tree-check runs THEN a FAIL says 1.0.7 has no heading and names R18, and exit is 1 - ## 1.0.13 does not satisfy 1.0.7 despite sharing its digits.
  • The gap note is not an entry. GIVEN CHANGELOG.md carries the recorded-gap heading naming 1.1.0, 1.1.1 and 1.0.2 - 1.0.12 WHEN VERSION reads 1.1.0 THEN 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.md exists and no manifest entry or EXEMPT row 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: ...@v4 line 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_SURFACES file 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.html contains <div class="bignum">20<small>rules</small></div> WHEN tree-check runs THEN it FAILs quoting 20 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.