Skip to content

code-anchor

raw source

Count-based drift gating ("3 commits behind blocks") measures the wrong thing: commit COUNT says nothing about whether the spec's contract was touched. The honest block criterion is spatial — a spec pins the unit of code that carries its contract, and only a change INSIDE that unit blocks. So a code: entry may carry an anchor, path#symbol, and the gate asks one question: did any commit since the spec's last version intersect the anchored unit's lines? Anchors are optional: an unanchored node keeps today's advisory-only drift, forever.

expanded spec

Vocabulary. An anchor names one top-level unit: a function, an arrow/const declaration (data too), a class, an enum, or a class method (#Class.method). A type/interface resolves but warns — anchoring a type is usually wrong. A code: entry may carry any number of selectors, all on the same exact base file — measured evidence: the drift-replay benchmark's multi-anchor roster (its 1–3 cap was annotation rubric, never product syntax — no selector-count cap exists). Selectors are OR: a commit hitting any blocks, counted once, the diagnostic naming the hit selectors. One-govern counts distinct base paths — cross-file selectors stay an error, multiple specs pinning one file stay ordinary. One structured parser reads both relations, refusing loud: duplicates, bare+scoped mixing, a selector on a glob/directory. Anchor verdicts are equally loud, never silent: dead (deleted/renamed — follow the rename or fix the spec), ambiguous (two same-named units), an unparseable current file, a language with no designated extractor, or an extractor that cannot run here — each a lint error naming its repair. When an extractor cannot run, the error also records that those anchors were skipped and remain unverified; the rest of lint continues, but the non-zero result cannot be reported as a pass.

That verdict is decided in ONE place, for every reader. Two readers ask it: the gate, judging the candidate TIP it is about to admit, and the eval freshness probe, judging the WORKING tree to decide whether a reading may still testify. Which text each reads is genuinely its own — a gate that judged a dirty worktree would admit a commit it never inspected — but the classification is not, and while each branched on dead/ambiguous itself the two had already drifted: the gate warned on a type-only unit, the probe never noticed one. So the per-selector verdict comes from one classifier and only the WORDING belongs to each caller. The failure this forecloses is the one that would be silent: a selector the gate calls dead while the freshness signal still treats its reading as testifiable — a measurement presented as valid for code whose anchor the gate has already rejected.

Scoped govern vs the file. A scoped governor claims named units, not the whole file: it stays out of the too-many-owners bound (governed-related) though spex spec owner still shows it as scoped. A scoped file's miss keeps the ordinary advisory drift warn by default; the committed lint.scopedCodeMiss: "ignore" (spex guide settings) silences only that advisory — never hit blocks, bare code: drift, integrity, acks, related semantics, or eval freshness. A related: row may carry selectors too: a hit is a soft warn naming the selector, a miss is silent; related stays never-block, never-ack, no eval freshness.

This vocabulary is READ by a second consumer with its own window: an eval scenario's code: axis (eval-core), which narrows a reading's staleness to the units it actually measures. The parse, extractor registry, resolution and hunk∩range engine are shared verbatim — there is no second anchor syntax — but the two windows are deliberately different, because they answer to different subjects. Spec drift asks about a NODE and so subtracts Spec-OK acks; eval freshness asks about a READING, which an ack never vindicates, so it takes the plain ancestry window. A selector verdict this side is never a block either: eval's whole lint layer is advisory, and a dead or ambiguous selector there stales its reading rather than stopping a commit.

Exact impact projection is a third consumer of the same event/project/range seam. Its base→head window does not subtract Spec-OK: it asks what the session changed, not whether a node acknowledged drift. A declaration on either side names an identity at that exact revision; the shared rename DAG projects it and every immutable event to the same terminal lineage keys, including deleted lineages and incomparable forks. Git window or hunk failure makes the projection unavailable, never an empty miss.

Judgment. The window is the spec's last version → the tip being judged: HEAD for an ordinary report/CI run, and a pending commit for a locally-authored candidate. It is the same ack-filtered set drift-by-ancestry's walk already derives. Per ordinary window commit, --unified=0 hunks are intersected with both immutable images of the unit: added lines against the result revision, deleted lines against the parent revision. The event retains both historical paths, so a later current-name projection never erases the preimage of a rename or deletion. A merge contributes only individual lines in its dense combined (--cc) diff that differ from every parent: an all-+ prefix is intersected with the result unit; an all-- prefix must intersect the same selector's unit in every parent. --combined-all-paths retains renamed merge parents. A mixed prefix such as + is inherited from at least one parent and stays out, even when an adjacent all-parent line puts both inside the same combined hunk. A clean transport merge has no owned line and stays neutral; a first-parent diff is deliberately forbidden because it would charge the merge again for already-attributed side-branch work. The same line-level predicate decides whether a merge changed spec.md and therefore created a version. A historical file version the extractor cannot parse counts as a conservative hit, flagged as such — over-warn beats silently missing a real change.

Algebraic boundary. The exact verdict is an event fold followed by a tip-relative projection and filter, not a bounded (version, debt) collapse. The fold accumulates immutable spec-version, governed-hit, acknowledgement and rename events. At the tip, rename identity is projected to the current node, drift-by-ancestry's full-history walk chooses ONE base from the maximal antichain of reachable versions, and ancestry plus acknowledgements filters the retained hits against that base. Incomparable versions have no join in reachability order; the walk-newest choice is a product rule, not a semilattice upper bound. The frontier operation itself is maximal union of parent frontiers; an authored spec change at a commit replaces that node's frontier with the commit, while a clean transport merge contributes no version event.

The information lower bound is concrete. Let one branch contain an anchored hit h followed by version vB, let its sibling contain version vA, and merge them without authoring a new spec line. Both parent tips report zero findings, but if the full-history walk selects vA, h is not reachable from that version and becomes debt at the merge. The hit history and the no-hit control produce the same pair-wise parent states (vA, empty) and (vB, empty), yet their merge verdicts differ, so no join over only (v, D) can recover the correct answer. The exact representation must retain h (or equivalent growing information) until read-time filtering; a later merge-authored spec version does collapse the frontier to that descendant, but the frontier width is unbounded between such commits. Replacing the single base with "covered by any frontier version" would form a semilattice, but would change this contract by letting one branch's version pardon another branch.

Renames add the same cost-conservation boundary on identity. A historical (commit, path) event is stable, while its current node is not. The DAG relation is a complete three-way judgment: a rename before the event means the old path was reused; the event before the rename moves its lineage to the target; incomparable branches retain the event-side path and follow the rename-side target, so one lineage can fork across current paths. A clean merge can own zero all-parent lines yet make arbitrarily many side-branch keys reachable. Materializing those keys charges the write; keeping only parent pointers charges the later read. The walk does not disappear, it moves. The exact, no-semantic-change route is therefore an incrementally maintainable event index plus read-time rename projection and reachability/ack filtering; it may reduce repeated reconstruction, but it cannot promise bounded state or history-independent O(1) verdict reads.

The worst-case bounds are real but loose on the reference history. Across 4,266 commits and 217 current nodes, 160 rename events form chains of at most 4 (mean 1.51), while anchor-hit identities derived from the complete drift event index grow from 249 at depth 1,002 to 550 at 2,497, 840 at 4,200 and 841 at the tip. Three older series were instrumentation errors. The 198 / 458 / 748 / 749 path-limited rev-list --no-merges <tip> -- <current-path> simplified away eight real single-parent hit events (indexed-only 8, simplified-only 0). The later 202 / 466 / 756 / 757 event-indexed series still read every historical blob through its current path and only intersected result-image ranges: it missed 86 real hits under six pre-rename paths and one same-path deletion hit. A record-level diff found 87 additions, zero removals, and re-proved every addition by immutable hunk-unit intersection. The 249 / 553 / 843 / 844 series then queried an ordinary rename through only its result path, which made Git render the result as a full-file addition. Reading both event image paths removes three false entries: two governors of one 100% rename and one R081 rename whose actual hunks miss blobPut on both sides. The reference proof projects the intended version base for all 217 nodes and compares normalized drift sets at 14 pinned tips. These measurements justify keeping the exact event/projection architecture and reducing its constants; they do not turn its asymptotic lower bound into a constant.

The local candidate gate is deliberately unmarked and ref-scoped. At reference-transaction's prepared phase it reads every payload row and considers only refs/heads/* updates whose new object is a commit. A new object already reachable from any ref or reflog is structural plumbing (reset, branch, checkout, an already-landed rewrite) and the hook exits. Otherwise the object is a new local branch commit, including an all-zero-old ref creation, and the hook runs spex spec lint --pending <new> before the ref advances. Git exposes no operation name here, so the predicate never guesses from parent process command lines. There is no commit-msg arm, marker file, TTL, message/tree/parent binding, or message projection. Consequently --no-verify cannot bypass the reference hook; the explicit local bypass is SPEXCODE_SKIP_LINT=1, named verbatim in every rejection. The default fetch namespace (refs/remotes/*) is outside this ref-scoped gate; an explicit fetch to refs/heads/* is intentionally judged like any other unreachable local ref update. Git does not provide enough information at this boundary to preserve a fetch exemption while also judging every replay operation, so this implementation chooses the ref-only, predictable option. That same predicate applies in a bare receiver: the absence of a .git subdirectory is a storage layout, not a structural-operation exemption. One proven fast classification remains: a single-parent candidate diff containing only paths absent from every candidate code: or related: declaration changes no governed subject or node metadata, so the hook's claim scope check allows it before full lint; .spec/.issues/* is the zero-process common case for dashboard writes, and the pre-commit hook performs the same Git-only classification before materialize/eval checks. The claim set comes from the candidate specs, not lint.governedRoots (that setting controls source discovery and a spec may explicitly govern a path outside it). Governance metadata (.spec nodes and config), any declared source path, and every multi-parent candidate stay on the full candidate lint path; a merge may introduce reachable side-branch debt even when its first-parent result tree only adds an issue file. If a ref update is rejected, Git leaves the ref, index, sequencer state, and merge state untouched; the diagnostic names both the continue and abort commands. The candidate lint is scoped to the candidate's changed paths and their governing nodes. It uses the same Git-derived history facts, rename projection, hunk/range intersection and ancestry filtering as the full verdict; changed paths narrow which nodes are judged, never which reachable events exist. Full spex spec lint keeps the complete graph and history verdict for CI and dashboards. The narrow verdict is equivalent to full lint for every governed node touched by the candidate; unrelated pre-existing debt is not re-litigated by a plumbing commit. Exact verdict reads may grow with reachable history because the event set and rename projection are part of the semantics; no persistent cache or depth-independent read is part of this contract.

For a pending merge, changed-path scope is the union of diffs against every parent. An ours merge may leave the result tree equal to its first parent while making a side-branch commit reachable; that newly reachable debt remains in the candidate anchor window and cannot be washed by a merge trailer.

A benchmark is not an oracle until a positive control proves that it can fail. Before accepting an equivalence run, execute one pinned case with known anchor debt and require the normalized set to contain that debt; only then compare candidate and baseline. Capture every channel that carries findings on every exit status. In particular, warning-only lint exits zero while writing findings to stderr, so a harness that reads stderr only on failure turns real debt into an empty set and makes two broken measurements look equal. The same rule excludes a fake CLI or receiver from standing in for the product surface being proved. This is a measurement invariant: a harness that does not prove it can observe a known failure may report agreement while both the product and the oracle are silently truncated or replaced by a fake dependency.

Oracle boundary. The product has one Git-derived history/projector path. A deliberately slow independent CLI or temporary real-Git fixture may serve as the correctness oracle; an in-process duplicate that shares the same projector cannot prove the projector itself. Correctness comparisons therefore require a positive control, separate process/home, and normalized findings including counts. Wall-clock improvement is not implied by this semantic equivalence.

The board's cold/full build must remain materially below the graph stream's patrol interval. Patrol is a last-resort self-healing invalidation; when a build outlives that interval, patrol can invalidate the still running build and amplify latency indefinitely. The index cost therefore protects both commit acceptance and dashboard liveness; changing the patrol period alone only moves the threshold.

The candidate lint run also owns one deliberately asymmetric governor-transition integrity check, separate from anchor drift: when the candidate deletes a spec node, its old HEAD blob supplies that node's former code: claims, and the candidate must either delete each governed subject or transfer it to a real node in the same tree. Spec-OK cannot waive removal of the governor itself. After such a commit is already HEAD, the deleted claim is no longer present to reconstruct this transition; ordinary HEAD lint therefore has only the current-tree coverage warning. “The same predicate at two tips” below describes the anchor-drift predicate, not this transition guard.

Spec-OK remains node-scoped acknowledgement metadata for full lint. A trailer on a content-bearing commit acknowledges that commit only; a tree-identical one-parent spex spec ack checkpoints reachable ancestors. The candidate gate does not use trailers as identity or as an arming signal, so scissors cleanup and every other Git message path are judged from the immutable commit object itself.

Git's default merge diff had additionally hidden both merge-authored anchor movement and merge-authored spec versions; cc makes these writes visible without re-billing ordinary branch content transported by the project's normal --no-ff merges. A candidate that owes several touched nodes emits one node-scoped error per debt, naming every node the author must answer. This is an honesty property of ref updates that reach the installed hook, not a claim that an uncovered hook or explicit SPEXCODE_SKIP_LINT=1 can be made impossible.

The cost is intentional and stated plainly: local acceptance is strictly narrower than CI acceptance. For example, code-only P1 followed by spec-only P2 is green when CI judges the final branch tip, but local authoring rejects P1; code and governing spec must land atomically, removing cross-commit iteration. This is an honesty property of commit paths that reach the installed, non-bypassed candidate gate, not a claim that Git makes lies or bypasses physically impossible: a meaningless spec byte edit can mechanically move the version, and an uncovered hook path or explicit bypass can still land first and acknowledge later.

Extraction is a language seam. Extractors are pure (content, filename) → units functions (no git, no cache, no fs — importable by an external scorer as-is), and every extension maps to exactly ONE designated extractor — no cross-language or cross-engine fallback. The JS family's designated extractor is ts-ast, backed by the governed repository's own TypeScript module so its parse matches the project's compiler. TypeScript is an OPTIONAL host capability, not SpexCode runtime cargo: repositories that use JS anchors normally already carry it, while Python and unanchored-JS adopters do not pay for a compiler they never invoke. The host candidate is probed through the actual parse API, not mere resolvability; an incompatible API is an error rather than a silent switch to another parser version. When the governed repository provides no usable TypeScript, lint emits an explicit integrity error (with a diagnostic naming the extractor and repair), skips JS-family anchor extraction, and continues the remaining checks; the non-zero result is a non-verification signal (never a silent or falsely passing anchor result), and the process does not throw. Other languages arrive as DATA rows to a generic engine (the heuristic declaration/boundary patterns today; a row may carry whatever config its engine needs — e.g. a tree-sitter grammar — so adding a language never adds a branch). Everything language-agnostic — immutable file-revision memoization, dead/ambiguous resolution, hunk∩range — lives outside the seam. A memo key includes every input to the pure extractor: object-hash algorithm and blob oid, filename semantics (including script kind), extractor/schema identity, host parser identity, and normalized parser configuration; same bytes under .ts and .tsx therefore never share a result by oid alone, and the result is independent of call order. Within one READ — a lint run, a board build, a CLI scan — every live anchored window is handed to the engine at once: it reads a historical (commit,path) image, its blob, and an ordinary (commit,path) hunk once, then applies each node's own selector set and emits findings in declaration order.

Two consumers ask two different questions of this engine, and they enter through different doors rather than through one call carrying a mode flag. ENUMERATION — spec drift and exact impact — IS the per-commit, per-selector list, so it must scan its whole window; that list is what names the debt an author must answer. EXISTENCE — an eval reading's anchor axis, whose entire verdict is one bit (eval-core) — is discharged by the FIRST hit, and scanning past it computes rows nobody reads. Existence is order-independent (a window either holds a hit or it does not), so stopping early cannot change a verdict; what it must never do is report "not found yet" as "no hit", so a window is settled only by a hit or by having been scanned to its end. Because which event hits is unknown until its images are parsed, the existence demand set is DISCOVERED rather than declared: the scan advances in rounds that still ask the whole unsettled corpus at once, doubling their width so the round count is bounded by hit DEPTH and never by how many readings were asked. A round narrowed to one node or one reading would re-fork the batch per unit — the same inversion the READ-is-the-unit rule below forbids. Enumeration takes its remaining window in a single slice and so keeps the one-round shape it has always had. Measured on the reference corpus, the two questions differ by 1,158 parsed file revisions (52.6 MB) versus 307 (21.9 MB) for the same 858 booleans.

A commit id is not an identity for any of them, and this is a correctness rule before it is a cost rule. What decides a hunk is the pair of images Git actually diffed and how Git was asked to present that diff, and BOTH are mutable behind a fixed commit id: refs/replace swaps the object a commit id names, a graft or an unshallow changes its parents, and a .gitattributes diff attribute — read from the WORKING TREE even for historical diffs — decides whether Git emits @@ at all or calls the path binary. Measured: one commit and path yield one hunk bare and ZERO under -diff, so an anchored contract's drift was silently unblockable by an attribute edit, and any store keyed on (commit,path) could serve either answer.

So this engine fixes both halves, inside the readers this node owns. RANGE SEMANTICS are PINNED on those readers across every class of ambient state that decides a boundary or the parse: presentation (text, no textconv, no external driver), the algorithm and its heuristics (an explicit algorithm, no indent heuristic — both otherwise repository config), hunk coalescing (inter-hunk context zero), rename candidacy (rename detection with no candidate limit), and COLOUR. Colour belongs in that list because it is the sharpest of them: under an ambient color.ui=always Git prefixes every hunk header with an ANSI escape, a header parse then matches NOTHING, and the engine reports no drift for every anchored path — a silently clean blocking gate for anyone whose global Git config turns colour on. Measured: two hunks become zero. Pinning colour off is therefore a correctness requirement of the gate, not tidiness. Identity is the ORDERED IMAGES — the result image and each parent image, in order, each named by its resolved blob oid and historical path — so replace, graft and unshallow all move the identity and are re-read, while an unchanged image set is reused. Those oids are already resolved by the read's one cat-file --batch-check, so completeness adds no child, no state and no second store. The algorithm's VALUE is itself a product decision, because the algorithms genuinely disagree about which lines an edit authored: measured, c a c a becoming c c a a a has myers authoring new lines 4–5 while histogram authors new lines 2–3, so a unit on line 3 is a HIT under one and a MISS under the other for the same commit. histogram is pinned, because it aligns an edit with the unit that actually moved, and for a BLOCKING gate the conservative reading is the honest one — Git's default myers would silently miss that drift. A pinned algorithm therefore settles what a verdict MEANS, not merely that it is reproducible.

Residual, explicit: the PERSISTED merge derivation is not pinned and is not this node's. The combined-diff rows the history event ledger stores are produced by source-of-truth's own streams under ambient Git interpretation, and its schema stamp is unchanged — so a ledger row written under one interpretation stays eligible under another, and a merge event's window membership can rest on a different reading than the range this engine then pins. Unifying that requires the ledger's schema identity to carry the interpretation, which is that node's contract to change and deliberately untouched here; the alternative — quietly re-interpreting the persisted stream without moving its schema — would leave old rows eligible under new semantics, which is worse than the split being written down.

Under that identity the demand set a batch sends Git is its MISSES, and a second READ in the same process asks only about images it has not read — work proportional to what MOVED, not to the corpus. The waste that removes: a re-lint after a single trunk commit or one dirty edit re-forked one log --patch per anchored path (22 on this tree, argv byte-identical to the previous run) and re-streamed every window blob, so a consumer that re-verdicts per tree state — manager-cockpit's review gate — paid the whole corpus per movement. The batch ends with that invocation; the memo it fills is per-image and holds no window, verdict or reachability, so it is neither a resident cache of results nor a second history truth, and an image set Git was never asked about is always asked. Git access stays batch/short-lived; no resident process. The READ is the unit deliberately: a consumer that batches something narrower — one node, one reading — re-forks the whole batch per unit and inverts the flag's purpose, so both the spec-drift and the eval-freshness consumers hand the engine their entire demand set at once. Batch width lengthens a queue rather than widening one process's argument vector or output: object reads and per-commit hunk queries are split into bounded requests whose results are order-independent, so a wider corpus costs more requests and never a truncated or rejected one.

Python is one such data row, designated for .py and .pyi. Its structural vocabulary is ordinary def, async def, and class declarations: module functions keep their bare name, while methods and nested declarations use their lexical qualified name (Class.method, outer.inner, Outer.Inner.method). Significant indentation supplies scope and unit boundaries, including decorator lines immediately attached to a declaration. This is deliberately a declaration extractor, not a Python runtime or full grammar: lambdas and callables assigned or attached dynamically, imported aliases, generated names, and declarations whose name is not on the first physical header line are outside the capability. Unsupported names fail as dead anchors, and duplicate qualified declarations stay ambiguous through the same language-agnostic resolver used by every extractor.