spec-lint¶
Provenance¶
- Source:
.spec/spexcode/spec-cli/source-of-truth/spec-lint/spec.md - Source SHA-256:
67da62d7b99aca4c0b6058ca14cf2da0da6604055933cc8e67777c9b2ad248ef
raw source¶
A spec is the ground truth for the code it governs, but nothing tied the two together, so code could
drift from its spec silently. The missing edge is a code: list in each node's frontmatter naming the
files it owns, plus a deterministic linter over that graph. Keep the spec↔code graph and authored
contract structure honest; whether prose is semantically good, including whether it sits at the right
altitude or whether its tree shape is too broad, is an opt-in health diagnosis rather than a production
gate. The graph's NAMES are part of its honesty too: an id is an unambiguous coordinate, a [[mention]]
must resolve, and a retired vocabulary must stay retired.
expanded spec¶
spex spec lint (cli.ts → lint.ts, over loadSpecs() from specs.ts) checks the graph and
deterministically verifiable contract structure. Errors block; warnings advise. The full registry (every
rule, its level, its one-line meaning) is printed by spex help spec and spex guide spec — the manual
lists ALL lint rules, always:
Machine report. spex spec lint --json is the public, versioned report projection. Its stdout is one
{ projection: "spex.spec-lint.report", schemaVersion: 1, sourceFiles, findings } JSON document and carries
no human lint rendering. sourceFiles is the sorted, repo-relative raw candidate set selected by this
node's coverage algebra at the linted tip; an adoption-integrity rejection that stops before source discovery
publishes []. findings is the same complete, deterministic finding sequence the text command renders;
every item has level (error or warn), rule, msg, and, when the rule applies to one, file and/or
spec. The report deliberately has no board-specific totals: a consumer derives its own coverage aggregate
from sourceFiles and the unique file values of coverage findings. Thus an external quality board runs
the porcelain command, parses stdout, and needs neither a package-root lookup nor imports of lint.ts or
source-files.ts. JSON changes representation only: it still writes a report when there are errors and exits
1 exactly when one or more findings have level: "error"; warnings alone remain exit 0. Without --json,
the human stderr rendering and its guidance remain unchanged.
One lint build opens the [[source-of-truth]] event ledger transaction around both its history indexes and anchor-hunk demand. The linter therefore shares one decoded, integrity-checked snapshot and at most one locked replacement with its anchor engine; it never causes a hunk lookup to reopen or rewrite that ledger.
Before constructing history indexes, lint checks a worktree's adoption boundary. An untracked .spec/ file
or spexcode.json is an integrity error naming the untracked project source of truth and the ordinary
git add .spec spexcode.json repair. Harness delivery files such as .codex/, .claude/, and AGENTS.md
are machine-local and are not part of this check. A staged candidate is judged from its candidate tree, so
staged adoption data is valid; an untracked seed cannot masquerade as a clean graph. Bare receivers have no
worktree state to inspect and continue with the immutable candidate tree.
- integrity (error): every file a spec lists in
code:exists — broken links block. A SELECTOR (path#symbolon either relation, [[code-anchor]]) must also resolve: dead (unit deleted/renamed), ambiguous (two same-named units), or an unparseable file all error with the repair spelled out. A language with no designated extractor remains an integrity error. A designated extractor whose dependency cannot run here is different: lint emits an explicit extractor-unavailable integrity error, names the repair, skips that language's anchor checks, and continues; the non-zero result records that the anchor is unverified, never a silent or falsely passing result. So do a relation's structural defects: a duplicate entry, a base path both bare and selector-scoped, and a selector on a glob or directory. Candidate lint also rejects deleting a governor while its governed subject remains present without transfer to another node; deleting the implementation with the node is valid retirement. - anchor-drift (error): a commit since the node's version intersected an ANCHORED unit's line
range (measured from the file as it existed at each commit) with no covering Spec-OK ack — the
blocking tier of drift, replacing the retired count-based
driftErrorThresholdgate. Same-file selectors are OR'd: one error per entry, hit selectors named, each commit counted once. Ordinary commits use their normal hunk; a merge uses only dense combined hunks different from every parent, so conflict resolution is visible while clean--no-fftransport is not charged twice. See [[code-anchor]]. - one-govern (error): a node governs (
code:) at most ONE file — DISTINCT base paths, so several selectors on one file are one subject — and drift/eval/ack have one unambiguous subject; keep the true subject, demote the rest torelated:([[governed-related]]). - living (error): a body stays current-state, with no
## vNchangelog headings — version history is read from git (recent/history tabs), not duplicated in prose. Fence-aware: a## v2inside a ``` block is sample text, not a violation. - id-format (error): a node's id — its leaf dir basename — passes an exact per-character whitelist and is unique tree-wide. This bullet is THE id vocabulary: defined once, here; [[mentions]] and [[id-url-safe]] reference it, never restate it. The table, judged on NFC (the mint's canonical form), deterministically and with no heuristics:
- allowed: ascii
[a-z0-9-]; any non-ascii unicode letter or number — CJK and every other letter script is a first-class id, exactly what the resolve machinery accepts; one optional leading dot (the reflexive.pluginsroot). - forbidden (by construction — anything off the whitelist): space,
/, uppercase Latin (lowercase is the Latin norm), control characters, and_— reserved as the mint's parent-qualification join, which is also why a mention TOKEN accepts_while a dir name never contains one.
Uniqueness keeps the leaf THE id: on a collision the mint ([[id-url-safe]]) must parent-qualify,
and every surface suddenly speaks a longer id than the dir name.
- mention (error): every [[id]] in body PROSE names a real node — a dangling mention is a broken
edge in the very graph the tree keeps honest. Retarget it or drop it; a placeholder ([[node]],
[[<id>]]) belongs in a fence or inline code span, which the rule exempts as sample text.
- coverage (warn): every source file is claimed by ≥1 spec via code: or related:. Source is
enumerated from git-tracked files (git ls-files), so governedRoots: ["."] safely means the whole
project. The source set is one explicit algebra: current regular text under those roots, selected by
optional sourceIncludeGlobs, minus SpexCode-owned data, sourceExcludeGlobs, and testGlobs. There is
no guessed language/path/file-type blacklist. The compatibility sourceExtensions lowers into include
globs before that same matcher. Eval lint reuses the resulting tracked set, and an empty set warns
"governing nothing" with every active policy knob. See [[adopt-nonweb-ergonomics]].
- drift (warn): a governed file has commits not reachable from its spec's latest version — true git
ancestry ([[drift-by-ancestry]]), never a log-position/date guess → maybe stale. A file
governed by several nodes drifts every owner — shared governance is ordinary, and each has a stake.
ALWAYS advisory: unanchored drift never blocks a commit; the blocking tier is anchor-drift above.
On a selector-scoped file's MISS this advisory stays by default; the committed
lint.scopedCodeMiss: "ignore" silences only it ([[code-anchor]]).
- related-drift (warn): the SOFT tier — a related: file moved ahead of the node; one summary line,
never the commit gate, never eval freshness. A selector-scoped related row warns per HIT (selector
named); its misses are silent.
- owners (warn): one summary line counting files governed WHOLE-FILE by > lint.maxOwners nodes
(default 3) — breadth's mirror on the file (too many owners, not too many children; below the cap is
ordinary). A selector-scoped governor claims units, not the file, so it stays out of the count. Remedy
blames the FILE: split it so each governor owns a module, or merge the nodes, or give it a single
foundation owner + related:. See [[governed-related]].
- confusable-id (warn): two leaf ids exactly one edit apart read as the same word — a typo in either
reaches a real, wrong node. Deliberately conservative (distance 1 only): hierarchy naming like
graph/graph-delivery and verb pairs like evidence-put/evidence-get never warn — better to miss a
borderline pair than to nag legitimate siblings. Distance counts code points, script-agnostic: a
CJK pair one character apart (节点/结点 — the classic homophone IME slip) warns like an ascii pair,
and a pure-CJK id never sits one edit from a pure-ascii one, so mixed-script trees get no cross-script
false positives.
Beside the graph rules sits the vocabulary backstop, [[dead-words]]: a CI grep gate over the RENAMED concepts' old names, scoped to product surfaces (strings, file names, node dir names) with prose exempt — lint keeps the graph honest, dead-words keeps its language from regressing.
Heuristic spec health is deliberately absent from this registry. Bare [[doctor]] owns the opt-in health
diagnosis, including the one altitude implementation and the one breadth implementation; lint neither
emits those findings nor carries their thresholds into the commit hook or CI. The retired
lint.maxChildren key is not a compatibility fallback: doctor names it and its
doctor.breadth.maxChildren replacement so an old settings file cannot silently keep a second owner.
Reusable as a product, not a SpexCode-only script: every project-shaped value (roots, source policy,
and ownership bounds) is read from an optional spexcode.json (lint key), defaulting to values tuned
to this tree; a different layout or language overrides what fits. loadConfig reads it through the shared
fail-loud readJsonConfig ([[portable-layout]]): an ABSENT file defaults silently, but a MALFORMED one
throws LOUD rather than quietly reverting the author's policy to defaults — a typo that green-washes the
very coverage or structural warnings they meant to enforce is a config error they must see.
No file hashes are stored — git is the hash database, so drift is derived live. When
drift exists, spex lint prints remediation guidance: drift can't be auto-fixed, so the agent must
find which link of intent→spec→link→structure→code broke and fix THAT — never patch the symptom.
One anchor predicate at two real tips, plus candidate transition integrity: the retired count gate
(lint.driftErrorThreshold) stays gone; an anchor hit is an ordinary lint ERROR. spex spec lint and CI
judge committed HEAD. At reference-transaction prepared, a refs/heads update whose new commit is not
already reachable from refs or reflogs runs this same lint over the immutable candidate oid before the ref
advances; no commit-msg marker or message projection is involved. Structural ref plumbing and remote fetch
paths stay inert. The candidate-only integrity rule above remains separate: it compares deleted governor
blobs from old HEAD with ownership in the candidate tree and rejects an orphaned surviving subject. The
explicit local bypass is SPEXCODE_SKIP_LINT=1; no installed hook means no local enforcement, so [[ci-gate]]
remains authoritative.
The single-parent candidate preflight narrows work only when the candidate's changed paths are outside every declared subject and outside governance metadata. Its exact-path, directory-prefix, and glob claims use the same pure relation matcher as ownership and eval impact, over declarations read from the immutable candidate tree. This preflight may skip construction of the history indexes; it never supplies a second drift verdict.
Spec-OK — acknowledging an implementation-only change¶
A commit ahead of a spec isn't always staleness — a refactor can change a governed file while the spec
stays true. Its Spec-OK: <node-id> trailer names the node it acknowledges (Spec-OK: A quiets only
A). An ack covers reachable ancestors only when it has exactly one parent and the same tree as that sole
parent. Every other ack is self-only, including a merge with an unchanged first-parent tree: the merge
still introduces newly reachable history. spex ack <node>… --reason "<why>"
stamps the trailer on an empty commit
above HEAD (--allow-empty --only, so a dirty index never rides along) — never an amend: drift's read
side quiets every drift commit reachable from an ack, so a child stamp covers exactly what amending
would, and it works on a trunk merge commit, where an amend re-authors the merge after MERGE_HEAD is
gone and [[main-guard]] rightly rejects it (the guard passes the stamp through its tree-unchanged door;
the same door waives this node's commit-local drift gate for the stamp — a no-content commit can't
introduce drift, and gating it on the REAL index would block an ack on the very drift it acknowledges
whenever unrelated work is staged).
The reason is required and recorded in the ack commit's message body — it forces the agent to
articulate why the spec still holds before quieting it, and an ack that quiets an anchor hit
([[code-anchor]]) is a strong claim whose why must be durable. A shared file drifts every governor, so
Spec-OK: accepts several ids — one ack per co-owner.
This split is also a correction to the old reader, not only support for a new writer. Before the split, a
content trailer was fed through the checkpoint reachability cover and could silently erase older or
cross-node debt; a tree-identical ours merge could do the same for a whole newly reachable side branch.
Both pending and HEAD lint now retain those debts, and report each affected node separately so the author
can name exactly the required nodes in repeated --trailer flags.
For the implementation commit currently being authored, Git's own
git commit --trailer "Spec-OK: <node-id>" is the in-commit route. The final message is already present on
the real candidate oid the prepared reference gate judges, and a trailer on a content-bearing commit acknowledges
only that commit — older unacknowledged drift remains. The commit body is the durable explanation. A
prior ack cannot cover a descendant, and a later empty ack cannot be created through a non-bypassed gate
until the rejected commit lands; this makes the in-commit form necessary for a complete honest workflow.
Hook absence, the explicit bypass, or a meaningless spec edit remain operational ways around truth that
Git cannot prevent.