v0.37.0

Released October 6, 2026 · All releases

Documentation drift. snippet, file-ref and expand can name the code they embed, by symbol or by match, instead of counting lines that move whenever the file is edited, and refrakt migrate snippets --fix converts existing line ranges. A reviewed= marker records that a person read an embedded slice, and refrakt snippet review --check reports when it changes. refrakt stale ranks documentation by how far the code it describes has moved since the page last changed.

Background: · ·

Changes

  • Address embedded source by name, and record that a human read it.
  • snippet, file-ref and expand can now name the region they embed instead of counting its lines — symbol="applyBemClasses" for a declaration, or match='"scripts"' for an arbitrary line. A lines= range silently re-points at whatever moved into those line numbers; an anchor either resolves to the thing it names or refuses, and the refusal says which of the three termination rules fired, so a broken reference reads as broken rather than as a different function.
  • extent= picks how far the region runs: auto (delimiters), dedent (indentation), section (siblings up to the next heading of the same level), or paired (token pairs). until= and through= stop it early. Languages that have no delimiters to count — Python, YAML, tag-paired markup — make auto refuse rather than return a plausible-looking span.
  • refrakt migrate snippets --fix converts existing lines= invocations, verifying the output is byte-identical before rewriting, and names every one it will not convert.
  • Embedded source can also carry a review marker — a reviewed= attribute holding two content hashes, recording that a human read that exact slice. refrakt snippet review writes those markers, never a person: --check reports which ones the target has outgrown, and --update --interactive shows the diff between what the reviewer actually read and what is there now. A change proven to be formatting-only re-stamps silently, so a repo-wide formatter run does not cost fifty reviews.
  • Rank documentation against the code it describes, and ask before the divergence exists.
  • refrakt stale ranks every reference a content page makes by one measure — commits touching the referenced file since the referring page itself last changed — and prints the commit subjects alongside it. "11 commits" is a number, but "generate breadcrumb positions from a declared index" is a person recognising that the page they wrote is out of date.
  • It ranks; it never fails. Findings do not affect the exit code, and there is no flag to make them. The cheapest way to turn any edge green is to edit the referring page, so a gate would train people to make trivial documentation edits to clear it — destroying the signal it measures. Being unable to measure at all (a shallow clone, no config) is a different event and does exit non-zero.
  • Four kinds of reference are found: snippet / file-ref path= attributes, internal links that carry an adjacent description, backticked repo paths in prose, and a new documents: frontmatter field listing the repository paths a page is about. A page's subject is often not in its words, and documents: says it outright. Unlike an inferred reference, an entry that resolves to no file is an error: the author asserted the relationship.
  • The refrakt.stale MCP tool answers the same survey, and one more question that is better timed. Called as refrakt.stale { touching: [paths] } it names the pages that document those files before they change, which involves no commit count and no git history at all — so it answers for a file created in the working tree and never committed.
  • An optional stale section in refrakt.config.json narrows what is measured: archival globs name referring pages that are historical records, generated globs name targets that change by construction. Both default to empty, and whatever they remove is counted in the report footer.
  • Fix validateManifest rejecting every theme the project produces.
  • ADR-024 made themes framework-agnostic — no target, and layouts declare regions rather than pointing at framework components. validateManifest was never updated, so it still demanded target, designTokens and layouts.*.component, and therefore rejected the manifest create-refrakt scaffolds and the manifest Lumina ships.
  • target — ThemeManifest.target was already optional and deprecated, with a doc comment naming the ADR. The validator simply hadn't caught up.
  • designTokens and layouts.*.component — required by the type but read by no runtime code. A field nothing reads is not a contract.
  • name and version remain required. Optional fields are still type-checked when present, so a typo is caught without the field being mandatory, and a framework theme that does declare target and layout components still validates.
  • ThemeManifest.designTokens and LayoutDefinition.component are now optional in the types to match.
  • The test fixture was a pre-ADR-024 Svelte theme, which is how the check drifted while its suite stayed green. It is now the manifest create-refrakt actually emits, with Lumina's own manifest added as a case.
  • Fix plan validate --against reporting a renamed plan entity as an ID collision.
  • collisionsFrom compared the two sides' slugs with the ID prefix stripped, so it forgave only a rename that kept its slug and moved directory. A slug change — the ordinary rename, since the slug is derived from the title — was reported as a collision. That fires on any title edit to an existing entity, and on plan validate --against after plan migrate filenames --apply renames files wholesale.
  • The check now tests what its own contract describes: a collision is two claimants surviving the merge. If the base's path is absent from the working tree, the merge applies the deletion and one claimant remains, so it is not reported. collisionsFrom takes the repo root to make that test.
  • Nothing is weakened. A new file claiming an ID whose base file survives is still flagged, and two files claiming one ID in the same tree is still an error from checkDuplicateIds, which runs before this.
  • What this deliberately does not catch is an ID reused for a different entity — the base's file deleted, a new one written under the same ID. That also leaves one claimant, so it is not a collision, but it silently repoints every reference to it. Detecting that needs content rather than paths, and it is not a reason to block a merge; a test pins the distinction so it stays deliberate.
  • The misreport was also actively dangerous, because the error text advises plan migrate ids --apply --git. Run against a rename, that renumbers a legitimate entity and breaks every reference to it.

v0.37.0 — Documentation drift

v0.37.0complete

v0.37.0 — Documentation drift

Documentation makes claims about code. Code changes independently. Nothing in this repository connects the two, at any of the three points where the connection can break — and all three have already broken, in the open, in pages about the very features that would have caught them.

Why

Three failures, each one level up from the last:

The wrong region is quoted. snippet and file-ref address files by line range only — a coordinate into a file nobody promised to hold still. 23 of 37 snippet invocations in site/content are line-addressed, and each has a 100% silent-wrong exposure to any edit above its range.

BUG-020 is the live instance: three pages carry a file-ref labelled SiteConfig pointing at packages/types/src/theme.ts lines 74–125. SiteConfig is not in that file. theme.ts is 104 lines long. A drawer captioned "SiteConfig" opens onto the back half of ThemeManifest, the whole of LayoutDefinition and the start of ComponentDefinition — on the documentation page for the rune whose addressing model is the subject of SPEC-131.

This is worse than the drift that spec predicted. The prediction was a range sliding out of alignment with a symbol still in place. What happened was a move, which no line range survives and no care in choosing the original range would have prevented.

The region is right and the prose is stale. SPEC-131 itself states, in the present tense, "SiteConfig occupies lines 74–161 of packages/types/src/theme.ts today". The sentence was accurate when written. The file changed, the prose did not, and nothing connected them. That is the ordinary case — editing a declaration is far more common than renaming one — and it is the one failure with no detector at all.

Nobody knows where to look. 28 of 237 content pages name a file. The other 209 can be entirely about a subsystem without ever mentioning one, so even a perfect check of quoted regions leaves most of the corpus unmeasured.

The through-line

v0.34.0's was "the checks were always written; nothing was listening." v0.36.0's was "the findings exist and nobody can reach them."

This one is different, and worth stating plainly because it changes what counts as done: there is no check here to wire up. Line ranges cannot be validated — a wrong slice is real code from the right file, which is why it renders plausibly and why no amount of care in authoring prevents it. The instrument has to be built before anything can listen to it.

What lands

Three specs, in a deliberate ladder — each answers the question the one before it leaves open.

SPEC-131 — address by name, not by coordinate. A regex anchor plus a delimiter-balanced extent, in the reader all three runes already share. Measured at 95.37% exact spans against the TypeScript compiler over 1,360 symbols, with a 0.07% silent-wrong rate and zero false alarms — against line numbers, which are 100% exposed.

  • WORK-597 — the masker and the language table. The lexical layer, split out because D10 makes the table a correctness boundary rather than a convenience. Numbered out of sequence deliberately: it was filed as WORK-586, which v0.36.0's close claimed first, so it was renumbered rather than shifting the ten items behind it. Order of work is the list here, not the IDs.
  • WORK-587 — anchor resolution, the auto extent, and the corpus test. The three termination rules each cost an iteration of the prototype to find and are not re-derivable; the corpus test ships here, not after.
  • WORK-588 — dedent, section, paired, and the explicit terminators. Closes the dead-end risk before anyone depends on the feature.
  • WORK-589 — reindent, highlight-match, and the coordinate frame. Small, and a prerequisite for two later items rather than a tail.
  • WORK-590 — the codemod and the migration. Where BUG-020 is closed.

SPEC-134 — review markers. An opt-in reviewed hash recording that a human read one version of a resolved slice.

  • WORK-591 — normalization, both hash levels, and --check.
  • WORK-592 — the review CLI. The phase that decides whether anyone uses the feature.
  • WORK-593 — stamp-on-insert and selective adoption.

SPEC-136 — staleness ranking. The cheap, wide signal underneath the precise one. Needs no new syntax, no marker, and no change to a single existing page.

  • WORK-594 — the edge index, the git scan, and refrakt stale.
  • WORK-595 — documents: and prose extraction, the classes that reach the other 209 pages.
  • WORK-596 — the MCP tool and the touching query.

Two tracks, not one chain

The milestone reads as a ladder and is not sequenced as one. SPEC-136 phase 1 extracts edges from path= attributes that exist today; it needs SPEC-131's resolver only for --precise, which is out of scope here. So WORK-594 starts on day one alongside WORK-597, and the two tracks meet only at WORK-596, which reads WORK-591's marker.

The payoff is prevention, and it is the last item

Everything here except WORK-596 is retrospective — it reports rot that has already happened, through a command someone must choose to run. That is SPEC-126's failure mode, and this milestone would be its fourth instance.

touching is the answer, and it inverts the question: "I am about to change this file — what documents it?", asked at edit time by whoever or whatever is making the change, before the divergence exists. It is the query an agent can be made to ask every time, which a human reliably will not.

Do not let it slip. It is placed last because an impact lookup that knows about snippet invocations but not about the guides misses the pages most worth updating — not because it is a tail.

Its only hard dependency is WORK-595, deliberately. WORK-591's marker is a soft one: hard-blocking on it would put the payoff behind the entire SPEC-131 chain, which is the longest path here. Marker awareness lands behind a capability check whenever WORK-591 does, and WORK-596 ships without it if SPEC-134 slips.

A stale prerequisite, now cleared

SPEC-134's Approach says the feature would "ship as a no-op with a CLI attached" until WORK-573 makes the diagnostic channel load-bearing, on the strength of WORK-554's finding that the adapter dev server prints no diagnostics at all.

WORK-575 shipped in v0.36.0 and fixed exactly that — one reporter at loadContent, printing in dev. A review-marker diagnostic now reaches someone editing documentation, which was the disqualifying row.

What WORK-573 still owns is whether an error-severity diagnostic fails a build — and SPEC-134 D7 says a fired marker is a review prompt, not a failure, so this milestone actively does not want it. No dependency in either direction. The spec text should be corrected when WORK-591 lands.

Deliberately not here

  • SPEC-131 phase 4 — adoption. Pointing the reference pages at declarations, and SPEC-126's deferred file-ref preview="drawer" provenance idea. Valuable, and it wants the migration's findings first.
  • SPEC-136 phase 4 — --precise. Region scoping via git log -L. It sharpens a signal the earlier phases have to prove is worth sharpening, and its cost needs measuring before it could ever be a default.
  • A config surface for the language table. SPEC-131 D15 builds the table as data behind a merge seam and commits to no languages or anchors key. WORK-590 is the input that decides the shape, and the named-anchor half is the one to build first when it ships.
  • Regex-literal lexing. D8's ~15 lines, the cause of the single silent-wrong case. WORK-590 picks it up only if the migration surfaces refusals that need it.
  • The editor validation rail. WORK-395's, unmilestoned. WORK-593 ships stamp-on-insert without it.
  • Making a build fail. WORK-573, per above.
  • Partial or field-level review markers. review-match= hashing only signature lines. Deferred until an author hits the need.

Minor, and additive — but a large surface

Following v0.36.0's habit of saying this plainly in the milestone rather than discovering it at release time. Nothing here is breaking, but the public surface this adds is the widest of any recent milestone:

SurfaceAdded
snippet / file-ref attributessymbol, match, occurrence, extent, until, through, doc, reindent, highlight-match, reviewed
CLIrefrakt snippet review (+ --check, --update, --interactive), refrakt stale
MCPrefrakt_stale
Frontmatterdocuments

Two defaults are worth naming in the changeset because they are the only places existing content could notice anything:

  • reindent defaults on for anchors and off for lines= (SPEC-131 D16), specifically so none of the 23 existing invocations change how they render. If that default is ever made uniform, it is a visual change to every one of them.
  • doc defaults on for symbol and off for match (D9). Only reachable through new attributes, so nothing existing moves.

WORK-590 rewrites 23 invocations in our own site/content. That is our content, not a consumer migration — no upgrade note is owed.

Two things that must not be traded away

The self-check is load-bearing, not a nicety. SPEC-131 D2: zero false alarms is a structural property, because the same lexer defect produces both the wrong extent and the imbalance. That argument holds only for extents derived from delimiter counting — so the check is required for auto and must stay inert everywhere else. Applying it globally would turn D4's escape hatch into a false-alarm generator and get the guard disabled for the case that needs it.

Nothing here may become a gate that green-ness satisfies. SPEC-136 D1 is the sharp case: the cheapest way to turn a staleness edge green is to edit the referring page, so a required check would train people to make trivial documentation edits to clear it — destroying the signal it measures. refrakt stale exits zero whatever it finds. snippet review --check has an exit code because a marker is a fact rather than a correlation, and even it is a review prompt rather than a failure.

Address embedded source by name, not by line number

SPEC-131shipped

Address embedded source by name, not by line number

snippet and file-ref address the files they embed by line range only. A line range is a coordinate into a file that nobody promised to hold still, so editing anything above the range silently repoints it — the page renders the wrong code, with no warning, looking entirely authoritative.

This spec adds a second addressing mode to the resolver both share: a regex anchor plus a delimiter-balanced extent. The anchor names what the author meant (symbol="SiteConfig"), so a rename or deletion becomes a loud, named failure instead of a wrong render.

snippetfile-refdocsdrifttoolingrunes

expand is not a third consumer, despite appearances. It sits in the same module but on readWholeSandboxedFile, which returns raw text for Markdoc.parse with no slicing and no [start, end]. expand embeds a document, not a region — it has no lines=, so it has none of the exposure above and nothing to migrate. Extending it to address regions would be a different feature (a partial include), and this spec does not propose one.

Deliberately not a language parser. Measurements below put an ~120-line implementation at 95.37% exact spans against the TypeScript compiler with a 0.07% silent-wrong rate, with no dependency on TypeScript and no per-language grammar.

Problem

Line ranges are silently wrong, by construction

SiteConfig occupies lines 74–161 of packages/types/src/theme.ts today, so a page quoting it writes {% snippet path="…/theme.ts" lines="74-161" /%}. That is not a reference to SiteConfig; it is a reference to a coordinate that happens to contain SiteConfig this week. Add three fields to EntityRoute, which begins twenty lines above at 54, and the same invocation now opens inside EntityRoute's tail and stops three fields short of the end of SiteConfig — under a heading that still says SiteConfig, in prose that still describes it.

Nothing fails. Nothing warns. The page is simply lying, and it keeps lying until a reader happens to compare it against the source.

This is worse than the drift SPEC-126 treats, which at least fails a test. A wrong slice is plausible — it is real code from the right file.

The exposure is already live

Not hypothetical. In site/content today:

count
{% snippet %} invocations37
…of which line-addressed (lines=)23
{% file-ref %} invocations10

Every one of those 23 has a 100% silent-wrong exposure to any edit above its range. Several already slice mid-construct: packages/runes/src/util.ts:42-50 opens on a blank line, and three separate lang-map.ts slices (3-7, 42-50) open in the middle of a comment block.

Most are demonstrations on the snippet doc page itself, pointed at deliberately-stable targets, so today's damage is low. The mechanism is what matters: lang-map.ts:15-35 is meant to be LANG_MAP, and it stays correct only for as long as nobody edits the fourteen lines above it.

It has already happened, to this spec's own example

The SiteConfig scenario above was written as a hypothetical. It is not one. Three published references — runes/file-ref.md:51, which renders, plus runes/drawer.md:155 and runes/file-ref.md:75, which sit inside fences and so teach the pattern rather than executing it — carry:

{% file-ref path="packages/types/src/theme.ts" lines="74-125" label="SiteConfig" preview="drawer" /%}
  • SiteConfig is not in theme.ts; it lives at packages/types/src/config.ts:42. The symbol moved files.
  • theme.ts is 104 lines long, so 21 of the 52 requested lines do not exist.
  • Lines 74–104 are the tail of ThemeManifest, then LayoutDefinition (86–93), then the start of ComponentDefinition (95–).

A drawer captioned "SiteConfig" opens onto the back half of one interface and the whole of two others, on the documentation page for the rune whose addressing model is the subject of this spec. Tracked as BUG-020, whose ranges have since been corrected (refrakt-md/refrakt#639) while leaving the addressing exposure this spec removes.

A fourth reference, found later and recorded on the bug, is the more instructive one: runes/file-ref.md:40 pointed at theme.ts 42–58 labelled SiteThemeConfig — right file, right symbol, and a range that had simply slid (the interface is 19–49). No move, no rename, nothing a careful author would have done differently. That is the ordinary case this spec exists for; the SiteConfig headline above is the rarer one.

This is worse than the failure the spec predicted. The prediction was drift — a range sliding out of alignment with a symbol that is still there. What happened was a move, which no line range can survive at all, and which no amount of care in choosing the original range would have prevented.

Two specs already flagged this and deferred it

SPEC-078 listed symbol resolution as an explicit non-goal:

Symbol resolution … Per-language complexity (TS exports, Python classes, Rust impls) and not needed for v1. Held as a future extension; authors supply explicit lines="42-58" in the meantime.

— and, in the same list, named the consequence as a known tax: "Hard-coded line ranges go stale when the source file is edited. Same problem snippet has today; not a new tax. Solved by symbol resolution when that lands."

SPEC-126 then hit the same wall from the other side. It rejected embedding SiteConfig directly — "the failure is silent and wrong rather than broken … That is worse than the drift being fixed here" — and proposed a one-off mitigation:

If snippet is ever pointed at theme.ts anyway, pair it with a test asserting the slice still starts with the expected export interface line. That converts silent-wrong into loud-fail, which is the minimum bar for a line-addressed reference.

This spec generalises that mitigation instead of writing it once, and answers SPEC-078's deferral with measurements rather than an estimate.

What the measurements say

The deferral rested on "per-language complexity". That is true of parsing and false of addressing, and the difference is worth showing rather than asserting.

Method. A prototype implementing the algorithm below was run over every top-level exported symbol in packages/*/src/**/*.ts and plugins/*/src/*.ts — 1,360 symbols — and its [start, end] line span compared against ts.SourceFile.statements from the TypeScript compiler. The compiler is used only to grade the prototype; it is not part of the proposal. Leading doc comments were excluded on both sides so the comparison measures the extent algorithm rather than a comment-absorption preference.

What the corpus does not cover. Every symbol in it is a top-level TypeScript declaration, so two things went unexercised and neither is disproven by the numbers below: anchors nested inside an enclosing block (which is what makes depth-relative counting necessary — see the note under the algorithm) and any file whose structure is not carried by braces (D11).

OutcomeCountRate
Span byte-identical to the compiler1,29795.37%
Loud failure — resolution refused, symbol named624.56%
Silent wrong — rendered the wrong span10.07%
False alarm — refused a correct extraction00%

The comparison that matters is not regex versus TypeScript. It is regex versus line numbers. Line numbers are 100% exposed to silent-wrong. Adopting a real TS parser over this approach would buy 0.07% and cost a heavyweight dependency in the content pipeline plus every non-TypeScript language.

Zero false alarms is a structural property, not luck

The self-check (below) refuses to render a slice that is not delimiter-balanced. The expectation going in was that an imperfect lexer would reject correct extractions and produce a flaky guard — which, by SPEC-126 D3's own reasoning, gets disabled. It does not, and the reason generalises:

When the lexer mis-reads a file, the extent comes out wrong and the slice comes out unbalanced. The same defect causes both, so the self-check catches its own failures. All 62 refusals were genuinely bad extractions; not one was a correct span wrongly rejected.

This is what makes the approach viable, and it is the part to protect in review. The self-check is load-bearing, not a nicety.

Note the scope of that argument, though: it holds because the extent and the imbalance share a cause, which is only true when the extent was derived from delimiter counting. It does not transfer to extents found some other way — D2.

Proposal

One resolver, three consumers

packages/runes/src/lib/read-file.ts already holds readSnippetFile, the sandboxed reader shared by snippet (SPEC-062) and file-ref (SPEC-078), with expand (SPEC-066) alongside it on readWholeSandboxedFile. It parses lines=, slices, and raises SnippetSandboxError. Containment already belongs to the ProjectFiles provider (SPEC-113), so this module is purely about which part of the file — exactly the concern being extended.

Resolution lands there, behind the existing ReadFileOptions shape. Both call sites (snippet-pipeline.ts, file-ref-resolve.ts) gain the capability by passing the new fields through.

A fourth consumer may be arriving: SPEC-129's pre-transform include rune is also path-addressed. If it ships, it should take this layer rather than growing a lines= of its own — which is the argument for putting the resolution in the shared reader rather than in either pipeline.

The attribute surface

{% snippet path="packages/types/src/config.ts" symbol="SiteConfig" /%}
{% snippet path="packages/lumina/styles/runes/hint.css" match="^\.rf-hint\s*\{" /%}
{% snippet path="scripts/deploy.py" match="^def build" extent="dedent" /%}
{% snippet path="README.md" match="^## Install" extent="section" /%}
{% snippet path="site/content/runes/tabs.md" match="^\{% tabs" extent="paired" /%}
{% file-ref path="packages/content/src/pipeline.ts" symbol="runPipeline" preview="drawer" /%}
AttributeMeaning
symbolNamed declaration. Builds an anchor regex from the keyword table.
matchRaw regex anchor. The general form; symbol is sugar over it.
occurrenceWhich match to take when the anchor is ambiguous, 1-based. Default 1. An escape hatch, not a naming form — D17.
untilRegex ending the extent, exclusive — the matching line is not included. Overrides extent.
throughAs until, but inclusive of the matching line. D12.
extentauto (delimiter balance, default), dedent (indentation), section (next sibling at the anchor's own level), or paired (matching close token). D11.
docInclude the preceding doc comment. Defaults to on for symbol, off for match — D9.
highlight-matchRegex(es) highlighting matching lines within the resolved slice. The anchor-native form of highlight. D13.
reindentStrip the slice's common leading whitespace. Defaults on for anchors, off for lines — D16.
linesUnchanged. Mutually exclusive with symbol / match.
highlight, linenumbersUnchanged — still file coordinates. D13.

The algorithm

Two independent halves. Finding the anchor is a regex problem and is easy. Finding the extent is not a regex problem — but it is not a grammar problem either.

1. Mask. Blank out comments and string/template literals so delimiter counting is safe, preserving newlines so line numbers survive. Template literals nest: ${ inside a backtick returns to code state, and the matching } returns to template state. ~40 lines, shared by the whole C family plus JSON and CSS. Not a grammar.

Markdown masking is recursive, and is the expensive one. For section and paired over Markdown the masker must blank fenced blocks and inline code spans — and on a docs site those fences frequently contain the very tokens being counted. site/content/runes/tabs.md:44 has {% tab %} in an inline code span; the pages most worth quoting with paired are exactly the ones full of fenced examples of the tags. Without this, a section anchor can match a ## Install that lives inside a fence, and a paired count can be thrown off by a tag nobody ever wrote as a tag. Budget this separately from the C-family masker; it is not the same ~40 lines.

2. Anchor. Matched against the raw source, not the masked source, with the mask used only to reject a match that lands inside a masked region — see the note below. For symbol=, the first line matching:

^([ \t]*)((export|public|private|pub|declare|default|async|abstract|static|final)\s+)*
(interface|type|class|const|let|var|function|enum|struct|impl|def|fn|func|trait)\s*\*?\s+<name>\b

For match=, the author's regex, applied per line. No match → refuse, naming the symbol and the file. More than one match → take the first, but warn, naming every line that matched — D14.

Raw for matching, masked for counting. Read in order, steps 1 and 2 imply anchoring against masked text — which blanks every string literal, so match='"scripts"' against package.json could never match anything, and neither could any anchor naming a JSON key, a quoted attribute selector, or text inside a template. The anchor must see the raw line.

The mask is still needed in the other direction: an anchor that matches inside a masked region — a .rf-hint { mentioned in a comment, a ## Install inside a fenced block — is a false positive and must be skipped rather than used. So the resolver needs the raw text and the mask together at this step, which the original ordering did not provide.

3. Extent (auto). Scan forward over the masked source tracking delimiter depth. Depth is measured relative to the anchor line, not to the start of the file — see the note below. Three rules, each of which cost an iteration of the prototype to find:

  • Only ; at anchor depth, or } closing a brace block opened at anchor depth, terminates. Parens and brackets nest but never end a statement. Without this, a multi-line parameter list ends the extent at its ) — the single largest failure class, worth 38 percentage points on its own.
  • A brace-less statement ends at the first line back at anchor depth that is not a continuation — looking ahead past blank and comment lines, and treating a next line starting | & . ? : , ) ] } => extends as a continuation. Without the lookahead, a union type with a comment between members ends early.
  • type aliases never terminate on a brace. A type-level object literal (T extends object ? { … } : T) is not a body.

Relative, not absolute. The prototype counted from file start, which is indistinguishable from anchor-relative for the top-level exported symbols it was measured on — every one of them sits at depth 0. It is wrong for any nested anchor: match='"scripts"' in package.json sits at depth 1 under the root object, so no } ever closes a depth-0 block and the extent runs to EOF. The same applies to a method inside a class, a rule inside @media, and a job under jobs:. Record the anchor's depth on entry and terminate on return to it.

packages/lumina/styles/runes/hint.css is the live proof, and it is this spec's own showcase example. The whole file is wrapped in @layer skin { (line 1, closing at line 67), so .rf-hint sits at depth 1 and no } ever closes a depth-0 block: under absolute counting the extent runs to EOF and returns all 67 lines instead of 4.

Reaching EOF without terminating is a refusal, never a return. If auto scans to the end of the file without finding its terminator, the extent is unknown — returning the remainder is precisely the plausible-wrong render this spec exists to prevent. This is not an exotic case: {% snippet path="classes.py" symbol="HttpClient" /%} is the most natural invocation a Python author can write, symbol resolves it correctly, and then the default auto looks for a ; or } that does not exist anywhere in the file. The refusal must name extent="dedent". The same rule applies to until and through that never match — D4.

4. Extent (dedent). Consume blank lines and lines indented deeper than the anchor. For Python, YAML, and anything else where indentation carries the block. This was the prototype's first attempt as a universal rule, and it failed there — a multi-line parameter list returns to the anchor's indent level before the body opens, so it truncated roughly one TypeScript declaration in eight. It works well as a declared strategy; it does not work as a default.

"Indented deeper" needs a stated tab-expansion width, taken from the language table. This repo indents TypeScript with tabs and YAML with spaces; a file mixing them compares a tab against four spaces and gets the comparison backwards without one.

5. Extent (section). Consume forward until the next sibling at the anchor's own level. The terminator is derived from the anchor line rather than supplied by the author: a ### heading ends at the next ^#{1,3}\s, a [tool.poetry] table ends at the next ^\[, a banner comment ends at the next banner of the same shape. The level-detecting patterns come from the language table (D10), and a format absent from it falls back to until / through.

6. Extent (paired). Balance a matching token pair rather than a delimiter: {% tabs %} / {% /tabs %}, <section> / </section>, {#if} / {/if}. The pairs come from the language table; nesting of the same token is counted, so an inner {% tab %} does not close the outer {% tabs %}. Three token shapes, and the table must distinguish them:

  • Asymmetric, nestable — {% tabs %} / {% /tabs %}, <section> / </section>. Count depth.
  • Symmetric — a ``` fence. Cannot nest, so the first recurrence closes.
  • Self-closing — {% snippet /%}, <img>, <br>. There is no close, so a paired scan anchored on one would run to EOF. Detect the self-closing form (/%}, />, a void-element list) and return the single line rather than scanning.

7. Self-check. For auto only: re-mask the extracted slice and verify {}, (), and [] all balance. Unbalanced → refuse. This is what converts the residual error rate from silent-wrong into loud-fail. paired self-checks on its own token stack; dedent, section, until and through do not delimiter-balance at all — D2.

8. Head. Two separate things attach above the anchor line, and they are not the same kind of thing:

  • Annotations — @Component(…), #[derive(Debug)], @dataclass. These are part of the declaration, not commentary. A class quoted without its decorator is wrong in a way a class quoted without its doc comment is not. They attach always, independent of doc.
  • The doc comment — a contiguous comment block reaching up from the anchor (or from the annotations above it) to the first blank line. Governed by doc, whose default depends on the addressing mode per D9.

Both need the comment and annotation prefixes to come from the language table, not from a union regex — see D10.

Measured against TypeScript's own JSDoc attachment (node.jsDoc, the only real oracle for "which comment documents this symbol"), the blank-line rule takes exactly the attached JSDoc for 749 of 756 resolvable symbols — 99.07%, with 4 over-absorptions and 3 under. Its limit is recorded in D9.

Generic in practice, not just in principle

The extent engine was run against packages/lumina/styles/runes/hint.css with match="^\.rf-hint\s*\{" and extracted the rule correctly — same code, no CSS knowledge. That is the evidence for match= being the real contract and symbol= being sugar: the engine never learns a language, it learns delimiters.

But note what that demonstrates and what it does not. CSS is brace-balanced, so the CSS result proves the engine is delimiter-agnostic, not format-agnostic. The formats that carry no braces at all — Markdown, YAML, TOML, and the tag-paired family — are the ones D11 exists to reach, and a Markdown or Svelte fixture is the test that would actually prove the claim.

Design decisions

D1 — A regex anchor and a balanced extent, not a parser. See the table above. The ~120 lines have no dependency, work on every brace language, and degrade loudly. A TS-specific resolver would be more accurate on TypeScript and useless on the CSS, Markdown, JSON, and shell that the docs also quote. If a future consumer needs true type-aware resolution (overload sets, declaration merging, re-exports), that is a different feature with a different spec.

D2 — The self-check is required for auto, and inert everywhere else. For auto it is the difference between a 4.63% error rate that fails loudly and a 4.63% error rate that renders wrong code. Never make it opt-out there, and if a future change makes the masker smarter the self-check gets more accurate, not less necessary.

It must not be applied globally, because the argument for it does not generalise. "Zero false alarms is a structural property" holds precisely because the same lexer defect produces both the wrong extent and the imbalance — which is only true when the extent was derived from delimiter counting. A section extent over Markdown prose did not come from delimiter counting, and prose is full of :) and (in)famous and half-quoted brackets. Balance-checking it would turn D4's escape hatch into a false-alarm generator and, by SPEC-126 D3's reasoning, get the guard disabled for the case that needs it. Scope the check to the strategy that earns it.

D3 — match= is the contract; symbol= is sugar over it. The keyword table is a convenience for the languages we ship docs in, and it will always be incomplete. Ship both from day one so an unsupported language is a slightly more verbose invocation rather than a dead end.

D4 — Never a dead end. until= / through= and lines= stay, permanently and documented. 4.56% of symbols in this repo cannot be resolved by the anchor engine, and an author who hits one needs a way through that is not "wait for a fix". Every refusal message must name the fallback:

snippet error: could not resolve symbol "renderFullPage" in
packages/html/src/page-shell.ts — the extracted slice is unbalanced, so the
extent is wrong. Use `through="^}"` to end the extent on a closing brace, or
`lines=` to address by line range.

The example says through, not until, deliberately: the brace line belongs in the slice. D12.

The fallback must itself fail loudly. An until or through whose regex never matches, like an auto extent that reaches EOF, refuses — it does not return the rest of the file. A fallback that fails silently is worse than no fallback, because every other refusal message points authors at it.

D5 — Look up lexical facts by language; never infer strategy from it. Guessing dedent for .py and auto for .ts would be right most of the time, and silently wrong the rest — reintroducing the failure mode this spec exists to remove. Default auto, and make the author say dedent, section or paired when they mean it.

The rule is about strategy, not about the language table as such — D10 already requires a per-language table for comment and annotation prefixes, and D11 adds heading shapes and token pairs to it. The distinction that matters: what a comment looks like in CSS is a lexical fact of the format with one correct answer, and a missing entry degrades to no head absorption rather than a guess. Which extent the author wanted is a choice only the author holds, and a wrong guess renders plausible wrong code. Table lookups for the first; never for the second.

D6 — Failures follow snippet's existing in-band error path. Resolution failures raise SnippetSandboxError, which resolveSnippetToFence already turns into an error fence carrying data-snippet-error, plus a ctx.error(…) diagnostic. The build continues and the failure is visible on the page. This spec introduces no new failure channel.

WORK-554 has now settled what that diagnostic does, and the answer is nothing: an error-severity PipelineWarning leaves npm run build and npm test at exit 0, and the adapter dev server does not print the summary at all. So the ctx.error half of this decision is not load-bearing, and the guarantee rests entirely on its other half — the error fence, which is visible on the page and is what makes the failure loud. That is the stronger half anyway: it reports at the site of the problem rather than in a build log.

The wording above is therefore accurate as written, but for a narrower reason than it first implied. Anyone relying on the ctx.error for CI enforcement should read WORK-573 first.

D7 — A --fix codemod, not a hand migration. Converting 23 invocations by hand is tedious and exactly the kind of change where a slip reintroduces the bug being fixed. The codemod reads each current slice, infers an anchor from its first meaningful line, rewrites the invocation, and verifies the new resolution produces a byte-identical slice — refusing to rewrite when it does not. The diff stays reviewable; the verification is what makes it trustworthy.

D8 — Regex literals are the known gap, and the reason the numbers are not better. The masker does not lex /…/, so a regex containing an unbalanced brace throws the depth count off. That is the cause of the single remaining silent-wrong case and, on inspection, most of the 62 refusals. Handling it is perhaps 15 more lines. Deferred to phase 3 rather than dropped, because the error it causes is overwhelmingly the safe kind, and because the phase-1 value does not depend on it.

D9 — doc defaults on for symbol, off for match. The two attributes make different promises, and the default has to follow the promise rather than be uniform for tidiness:

  • symbol="SiteConfig" names an entity and delegates boundary-finding entirely. Having chosen the start line itself, the resolver also owns whether the doc comment is part of what was asked for — and it is. A reference quoting a declaration without its documentation is a worse quotation.
  • match="^\.rf-hint\s*\{" names a line. The author has already made the boundary decision; that is what a raw anchor is for. Walking upward past the line their regex matched silently overrides them. An author who wants the comment can anchor on the comment, or pass doc=true.

So doc is tri-state: unset takes the mode's default, doc=true forces inclusion, doc=false forces exclusion.

This is not the sniffing D5 forbids. D5 rules out inferring behaviour from the file, where a wrong guess is silently wrong and the author has no signal that a guess happened. D9 infers from which attribute the author wrote — an explicit authored choice — and a wrong doc comment is visible on the rendered page. Different risk class, opposite conclusion.

The known limit: abutting comments cannot be told apart. A blank line is the only available signal that two adjacent comments are distinct, so when a comment documenting one thing directly abuts the comment documenting the next, both are taken. packages/runes/src/tags/card.ts is the live example — a 21-line JSDoc for the card rune, then two // blocks with no blank line between, then cardSections. The walk presents the card rune's docblock as documentation for cardSections.

This is not fixable by heuristic and should not be chased. It is 4 cases in 756, it is visible rather than silent, and the fix belongs in the source file (a blank line) rather than the resolver. Document it; do not add cleverness.

The file-header variant deserves naming separately, because it hits the first declaration of every file rather than 4 scattered ones: a license block, an SPDX header, a #!/usr/bin/env line, or a module docblock abutting the first symbol is absorbed as that symbol's documentation. lang-map.ts escapes only because line 13 happens to be blank, which is luck rather than structure. Same limit, same non-fix, much larger blast radius — say so in the docs so authors reach for doc=false rather than filing it as a bug.

D10 — Comment and annotation prefixes come from the language table, never a union regex. The prototype walks ^\s*(//|#|\*|/\*) across all languages. In CSS that eats #header { … } as a comment; in a language where # is significant it will find something else. There are zero such lines in Lumina today, so this repo would not notice — but refrakt ships to projects whose files we have never seen, and a head that silently swallows a preceding rule is precisely the failure class this spec exists to remove. The prefixes belong in the same per-language table that supplies symbol='s keywords, and a language absent from the table gets no head absorption rather than a guessed one.

D11 — There are four structural families, not two. symbol versus match is the anchor axis and D3 settles it: nothing non-code needs a new anchor, because match="^## Install" names a Markdown section perfectly well. The gap is on the extent axis, where auto and dedent cover two families and the other two are where every non-code target lives:

FamilyTerminatorExamplesStrategy
Balanced delimitersmatching }TS, CSS, JSONauto
Indentationdedent to anchor levelPython, YAMLdedent
Sibling-terminatednext peer at the anchor's levelMarkdown headings, TOML tables, ini, banner comments, diff hunkssection
Paired tokensmatching close tokenMarkdoc, HTML, Svelte, Vue, JSX, fenced blockspaired

Both additions are earned, not speculative:

  • Sibling-terminated is already in this spec, pushed onto the author. The README.md example above originally read until="^## ", which works only because the author did the heading-level arithmetic by hand. Anchor on a ### and that terminator is wrong; you need ^#{1,3}\s. A terminator derivable from the anchor line is a strategy, not an author burden — and this repo quotes Markdown constantly (lang="markdoc" snippets on the docs pages, and every file under plan/).
  • Paired tokens fail silently, which is the failure class this spec exists to remove. LANG_MAP already ships markdoc, svelte, vue, html and jsx. Point auto at a Svelte file anchored on <script> and delimiter counting terminates at the first } closing a brace block at anchor depth — somewhere inside the script body. The slice balances, so the D2 self-check passes and the page renders a plausible wrong span. The measurements were TypeScript-only, so this is unexercised rather than disproven, and the self-check cannot catch it: the extent is wrong because the wrong family was used, not because the lexer misread anything.

auto stays the default. An author who points it at a tag-paired file should get a refusal or a documented mis-extent, not a new guess — D5.

D12 — until is exclusive, through is inclusive, and neither is a default. The spec did not say which, and the two families want opposite answers: until="^}" in code wants the terminator line in, until="^## " in Markdown wants it out. Either default is silently wrong half the time, on the attribute D4 designates as the escape hatch of last resort — the one place a silent wrong answer is least affordable. Two attributes, each saying what it means, costs one row in a table and removes the ambiguity entirely.

D13 — highlight and linenumbers keep file coordinates; highlight-match is the anchor-native form. Both attributes are currently defined in terms of lines=. From the schema in snippet.ts:

linenumbers — "Starting number derives from the lines range start (e.g. lines="74-125" → first line is 74), so numbers reflect the file's real offsets."

highlight — "Indices are file coordinates (same frame as lines=)."

Remove lines= and neither has a defined frame. This is not an oversight to patch later: {% snippet path="…/lang-map.ts" lines="10-40" linenumbers=true highlight="18-22" /%} is live on the snippet doc page today, and phase 3's codemod has to rewrite it into something.

Keep the frame. The resolver already produces a [start, end] in file coordinates, so linenumbers continues to start at start and highlight continues to mean file lines — no new concept, no breakage, and the displayed numbers stay meaningful as a pointer back into the real file.

What changes is that a numeric highlight under an anchor is no longer something the author can write from knowledge: they would have to look up the current line numbers, which re-couples the invocation to the coordinates this spec exists to decouple it from. So add highlight-match= — one or more regexes, each highlighting the lines they match within the resolved slice. Numeric highlight stays legal and stays useful with lines=; under an anchor it carries the old exposure, and the docs should say so plainly rather than forbidding it.

D14 — An ambiguous anchor warns, and names every match. match= and symbol= both take the first hit. For overload sets that is a stated non-goal and fine. It generalises badly: symbol="get" in a file of classes takes whichever get comes first, and a second .rf-hint rule inside a @media block is indistinguishable from the intended one.

Taking the first match is the right behaviour — refusing would make the common case worse for no safety gain, since the first match is usually correct. But taking it silently turns an ambiguity the resolver can see into one the author cannot. Emit a warning naming each matching line, and keep rendering. Cheap, and it converts a silent ambiguity into a visible one.

occurrence= is that fuller answer, and something now needs it — D17. The enumeration this warning requires is the same list the selector indexes into, so the two are one mechanism rather than two.

D15 — The language table is data, and is built to be merged into. The split between what is declarative here and what is not falls in a useful place:

Declarative — belongs in the tableEngine — stays code
symbol keywords, declaration modifiersthe masker state machine
comment and annotation prefixesthe depth counter
string / template delimiterscontinuation lookahead
token pairs and their shape (D11)the type-alias special case
heading shape and level capturethe balance self-check
tab width (step 4)

The symbol= anchor is a template with one hole — modifiers* keyword \s+ {name} — which is as declarative as it gets. That is a consequence of D1: a parser could not be table-driven, a delimiter engine can.

D3 already concedes the table "will always be incomplete". Two extension surfaces follow from that, and they are different features:

  • Declaring a language — comments, strings, delimiters, keywords, token pairs for a format core does not ship. Terraform's resource "…" "…", SQL's CREATE TABLE, Ruby and Lua's def/end (which need paired, not auto).
  • Declaring a named anchor — site-scoped sugar over match=, bundling match, extent and doc into one authored decision: anchor="rune" name="hint" resolving through a config entry rather than a regex copy-pasted across twenty pages.

Ship neither surface in this spec. Nothing has hit the wall yet, and the right shape will be much better informed by phase 3, which is the first time the resolver meets languages chosen by need rather than by convenience. What this spec does commit to is structuring the built-in table as data behind a mergeLanguages() seam mirroring mergeThemeConfig() — costing nothing now, where retrofitting a hard-coded table later is the expensive part. When the surface does ship, the named-anchor half is the one to build first: it is pure aliasing with no engine involvement, and it gives drift a name, so a broken pattern is one wrong config entry rather than twenty wrong pages.

Two things to get right when it lands. This is not the sniffing D5 forbids — a config entry saying ".tf uses paired with these tokens" is the project author declaring a strategy once instead of per-invocation, the same class of explicit authored choice as D9's inference from which attribute was written. The engine still never guesses. And {name} interpolation must be escaped: a symbol name containing . or ( spliced raw into a regex is injection from content.

D16 — reindent strips common leading whitespace, on by default for anchors and off for lines. Anchoring makes nested targets easy for the first time — a class method, a key inside "scripts", a rule inside @media, a step under jobs:. Every one of those slices carries one or two levels of leading indentation it did not ask for, and renders with a ragged left edge that wastes horizontal space a code block does not have. Removing the common prefix preserves relative structure exactly, so the result stays valid in indentation-significant languages: a dedented Python method is a function, a dedented YAML subtree is that subtree rooted.

The default follows the addressing mode, for the same reason D9's does. An author writing lines="10-40" has seen the file and chosen those columns; changing how they render would be a silent visual change to all 23 existing invocations. An author writing symbol="…" has delegated boundary-finding entirely and never saw a column number — the resolver picked the region, so it owns presenting it legibly. reindent=false and reindent=true force either way.

One interaction to respect: D7's codemod verifies that a rewritten invocation produces a byte-identical slice. That comparison must run before reindent, or every nested target fails verification for a difference the codemod itself introduced.

D17 — occurrence= selects among matches, and belongs to the escape hatches rather than to the naming forms. D14 deferred this "until something needs it". D11's paired is what needs it: making Markdoc, HTML and Svelte addressable is precisely what produces files with many structurally identical anchors, and the repository's own front page is the example. site/content/index.md carries one {% hero %}, four {% feature %} and two {% sandbox %}. Quoting the second feature is a reasonable thing to want, and without a selector the only route is back to lines=.

It is nearly free. D14 already requires enumerating every match in order to name each matching line in its warning. The selector is an index into a list the resolver has already built; there is no second traversal and no new concept in the engine.

Be honest about what it is. occurrence=3 is counting, and this spec exists to replace counting with naming. It is a better count than a line number — inserting unrelated content above does not move it, only inserting another match does — but it is ordinal addressing, and it drifts the moment someone adds a fifth feature above the fourth.

So it is classified with lines=, until= and through= under D4, not with symbol= under D9, and three consequences follow:

  • The ambiguity warning still fires. occurrence= selects a match; it does not assert the author disambiguated correctly. Suppressing the warning would turn the one visible signal into silence, which is D14's whole subject.
  • An out-of-range selector refuses, naming how many matches were found and where — never silently clamping to the last, and never falling back to the first. A clamp is the plausible-wrong render this spec exists to prevent.
  • The docs steer to a distinguishing regex first. match="^\{% feature align=\"right\"" names what the author meant; occurrence=2 counts to it. Prefer the former wherever the anchors differ at all, and reach for occurrence= only when they are genuinely identical.

D15's named anchors are the better long-term answer for a repeated structure worth quoting often, since they give the pattern a name in one config entry rather than an ordinal in twenty pages. That surface is still deferred; this selector does not prejudge its shape.

Non-goals

  • Cross-file resolution. symbol="SiteConfig" searches the file named by path=. No import following, no project-wide symbol index.
  • Type-aware resolution. Overload sets, declaration merging, and re-exports resolve to the first textual match. D1.
  • Rename tracking. A renamed symbol is a loud failure, which is the point. Auto-following renames would restore the silence.
  • Replacing lines=. It stays as the escape hatch and for files with no usable structure. D4.
  • New preview targets. preview="drawer" behaviour is unchanged; this spec only changes how the referenced region is located.
  • Checking that the prose still describes the code. This spec guarantees the right region is quoted. A symbol can resolve perfectly and have changed meaning entirely, leaving the paragraph beside it false with nothing to detect it. That is a different failure class with a different mechanism — SPEC-134, which builds on this layer and depends on D16's reindent as its first normalization step.
  • A config surface for the language table. The table is built as data and merged (D15), but refrakt.config.json gains no languages or anchors key in this spec. Deferred until phase 3 shows which formats authors actually reach for.
  • Explicit region markers in the source. // #region example … // #endregion, resolved through the comment-prefix table D10 already builds, is the one mode that can address part of a construct — "the six lines inside runPipeline that illustrate the point" is expressible by neither symbol nor any extent above. It also inverts the drift contract usefully: the anchor lives in the source file, where a refactorer sees it, rather than in a doc page they never open, and it survives the rename that makes symbol= fail. Deferred rather than dismissed, on three counts — it requires editing every quoted file, it must bypass the D2 self-check by construction (a mid-function region is deliberately unbalanced), and nothing in site/content wants it yet. Its own spec when one does.

Acceptance Criteria

  • readSnippetFile resolves symbol and match anchors in addition to lines, and both snippet and file-ref gain the capability from that one change
  • expand is unchanged — it reads whole files through readWholeSandboxedFile and gains no anchor attributes
  • symbol builds its anchor from a documented keyword table; match accepts a raw regex; the two are mutually exclusive with each other and with lines
  • extent accepts auto (default), dedent, section, and paired; until / through override all four
  • until excludes its matching line and through includes it; neither is inferred from the file or the strategy
  • The masker handles line comments, block comments, single- and double-quoted strings, and template literals including nested ${}
  • For Markdown, the masker also blanks fenced blocks and inline code spans, covered by a test anchoring past a ## heading and a {% %} tag that appear inside a fence
  • Anchors match against raw source; a match landing inside a masked region is skipped, covered by tests for match='"scripts"' on package.json (must resolve) and an anchor mentioned only in a comment (must be skipped)
  • An auto extent reaching EOF without terminating refuses and names extent="dedent"; an until / through that never matches refuses the same way
  • An anchor matching more than once takes the first and warns, naming every matching line
  • occurrence selects the Nth match, 1-based, defaulting to 1, and reads from the same enumeration the ambiguity warning names
  • occurrence does not suppress the ambiguity warning
  • An occurrence beyond the number of matches refuses, naming how many were found and where — never clamping to the last or falling back to the first
  • The docs present occurrence as an escape hatch beside lines / until / through, and steer authors at a distinguishing regex first
  • dedent expands tabs at a width taken from the language table
  • paired handles asymmetric, symmetric, and self-closing token shapes; a self-closing anchor returns one line rather than scanning to EOF
  • linenumbers and numeric highlight stay in file coordinates under an anchor; highlight-match highlights by regex within the resolved slice
  • The language table is a data structure behind a merge seam, with no hard-coded per-language branching in the engine
  • reindent strips the slice's common leading whitespace, defaulting on for symbol / match and off for lines, covered by a test on a nested target
  • The codemod's byte-identical check compares the slice before reindent is applied
  • The extent terminates only on a ; at anchor depth or a } closing a brace block opened at anchor depth; parens and brackets nest without terminating
  • Depth is measured relative to the anchor line, covered by a test anchoring on a nested target (a package.json key, a class method, a rule inside @media)
  • Brace-less statements use continuation lookahead that skips blank and comment lines
  • type aliases do not terminate on a brace
  • Under extent="auto", an extracted slice that is not delimiter-balanced is refused, never rendered
  • The balance self-check does not run for dedent, section, until or through, covered by a test extracting a prose Markdown section containing unbalanced brackets
  • extent="section" derives its terminator from the anchor's own level, covered by tests on a ### Markdown heading and a TOML table
  • extent="paired" balances nested same-name tokens, covered by a test extracting an outer {% tabs %} containing inner {% tab %} blocks
  • Annotations (@Component, #[derive], @dataclass) attach to the symbol always, independent of doc
  • doc is tri-state: unset defaults to on for symbol and off for match; doc=true and doc=false force the choice
  • Comment prefixes, annotation prefixes, heading/section shapes, and token pairs all come from the per-language table; a language absent from it gets no head absorption and no section / paired support rather than a guessed one
  • A test covers the abutting-comment limit (D9) so the behaviour is pinned rather than accidental
  • Every refusal names the file, the anchor, the reason, and the until= / through= / lines= fallback
  • Failures use the existing error-fence path and emit a ctx.error diagnostic — no new failure channel
  • A regression corpus test scores the resolver against the repo's own sources and asserts the silent-wrong count stays at or below its recorded baseline
  • extent="dedent" is covered by tests against an indentation-structured fixture
  • The engine is proven delimiter-agnostic by a test extracting a CSS rule via match=, and format-agnostic by tests on a brace-free format (Markdown or YAML) and a tag-paired one (Svelte or Markdoc)
  • extent="auto" against a tag-paired file either refuses or is documented as unsupported — never renders a brace-terminated span from a tag-structured source
  • A --fix codemod converts lines= invocations to anchors, verifying byte-identical output and refusing to rewrite when it differs
  • The 23 line-addressed snippets in site/content are migrated, or individually justified as intentionally line-addressed
  • snippet and file-ref doc pages document the new attributes, including the fallbacks

Approach

Four phases, each shippable alone.

  1. The resolver and its corpus test. The masker, anchor, auto extent, and self-check in read-file.ts, wired through both call sites. The corpus test is part of this phase, not a follow-up: it is the only thing that will catch a regression in a heuristic, and it is cheap because the repo is its own corpus. Record the baseline counts in the test so a change that trades loud-fail for silent-wrong cannot pass unnoticed.
  2. The remaining extents, until / through, and the docs. dedent, section and paired (D11) plus the two explicit terminators (D12). Completes the surface and closes D4's dead-end risk before anyone depends on the feature. section and paired are the ones that make the non-TypeScript half of site/content addressable at all, so phase 3's migration should not start before they land.
  3. The codemod and the migration. Converts the 23 existing invocations and removes the live exposure, BUG-020 included. Regex-literal lexing (D8) lands here if the migration surfaces refusals that need it. The codemod must also handle companion attributes: an invocation carrying highlight= needs those coordinates preserved or rewritten as highlight-match= (D13), and byte-identical verification covers the slice only, not the highlight, so that rewrite needs its own check. This phase is also the input to D15 — record which languages and formats actually came up, because that is what decides the shape of the config surface if one ships.
  4. Adoption. Point the reference pages at declarations. This is where SPEC-126's deferred file-ref preview="drawer" provenance idea becomes safe to build.

Budget the masker, not the regex. The anchor is an afternoon. The masker, the three termination rules, and the corpus test are the work, and the termination rules in particular are not guessable — each was found by measuring against a real corpus, and re-deriving them from first principles will cost more than reading them off this spec.

Open questions

  • Does ctx.error fail anything? Answered — it does not. WORK-554 planted an error-severity diagnostic and measured every surface: npm run build exits 0, npm test exits 0, and the dev server never prints the summary. D6 is updated to state this. The practical consequence for this spec is small, because D6's guarantee rests on the error fence rather than the diagnostic — but phase 3 must not be written as though a ctx.error gates anything. WORK-573 tracks making the channel load-bearing.
  • Should symbol= support a member path? symbol="SiteConfig.baseUrl" would let a reference quote one field. The anchor engine can do it — nested anchors and a dedent-like extent — but it multiplies the failure surface, and no current page wants it. Defer until one does.
  • Annotation handling is specified but untested. This repo contains zero decorators and zero CSS lines beginning #, so neither D10's hazard nor the annotation rule was exercised by the measurements — both are reasoned, not observed. The corpus test cannot cover them from this repo's sources alone and will need hand-built fixtures.
  • Is match= a regex or a literal by default? A raw regex is more powerful and more likely to be miswritten by an author who does not realise . and ( are special. A match-type="literal|regex" attribute is the obvious answer and may be over-engineering for the first pass.

References

  • SPEC-078 — file-ref; listed symbol resolution as a non-goal and named line-range staleness as the tax this spec repays
  • SPEC-062 — snippet; the origin of the lines= addressing model and the error-fence path D6 reuses
  • SPEC-066 — expand; in the same module but on readWholeSandboxedFile, and out of scope: it embeds documents, not regions
  • SPEC-113 — the ProjectFiles seam that owns containment, unchanged by this spec
  • SPEC-129 — the pre-transform include rune. A fourth path-addressed rune: if it lands, it should take the same addressing layer rather than growing its own lines=
  • SPEC-126 — rejected line-addressed embedding for the config reference and proposed the one-off assertion this spec generalises
  • BUG-020 — the observed instance: three live file-ref drawers labelled SiteConfig rendering three unrelated interfaces
  • SPEC-134 — review markers; the next failure class up, where the region resolves correctly and the prose beside it no longer holds
  • packages/runes/src/lib/read-file.ts — the shared reader this extends

Staleness ranking for documentation references

SPEC-136shipped

Staleness ranking for documentation references

SPEC-134 records that a human read one slice of code, and tells them when that exact slice changes. It is precise, and it costs a stamp per invocation — so it only ever covers the references someone already suspected.

This spec adds the cheap, wide signal underneath it. Documentation already declares edges into the repository — snippet path=, file-ref path=, a backticked module path in a paragraph. Indexing those costs nothing per reference and needs no new syntax, no marker and no change to a single existing page.

Extraction alone reaches 28 of this repository's 237 content pages, because a page can be entirely about a subsystem without ever naming a file in it. So one optional frontmatter field, documents:, lets an author state what extraction cannot recover — written once per page, never re-stamped. See D13.

It never verifies anything, and it never fails a build. It answers two questions nothing in the repository answers today, and the second one matters more:

driftdocsgitclimcpai-workflowtoolingdx
  • Of the several hundred places documentation makes a claim about code, which should someone look at first? — a survey, run occasionally, over rot that has already happened.
  • I am about to change this file. What documents it? — asked at edit time, by whoever or whatever is making the change, before the divergence exists.

Both read the same edge index. The first is a list of debts; the second is how you avoid taking one on — and it is the query an agent can be made to ask every time, which a human reliably will not. See D12.

Problem

Every page declares edges, and nothing measures them

A documentation page that mentions packages/runes/src/config.ts is making a claim about that file. So is {% snippet path="…" symbol="…" /%}. So is a done work item carrying source="SPEC-128". These are dependency edges from prose to a thing that changes independently of the prose.

The pipeline already extracts the first kind (the shared reader, SPEC-062 / SPEC-078) and the third (extractRefs, scanner-core.ts:175). Git already records every commit to both ends. Nobody has ever multiplied the two.

The result is that documentation rot is discovered the way it has always been discovered here: a human happens to read a page and notices it is wrong. That is how BUG-020 was found, and how the defect below was found — by writing this spec, rather than by any check.

A real instance, found by the measure this spec proposes

site/content/extend/rune-authoring/authoring-overview.md is the page CLAUDE.md directs every agent to read before writing a rune. It last changed 2026-07-09. packages/runes/src/config.ts, which it documents, has taken 11 commits since, most recently 2026-09-16.

The page teaches the engine config with a worked Hint example:

Hint: {
  block: 'hint',
  defaultDensity: 'compact',
  modifiers: { hintType: { source: 'meta', default: 'note' } },
  contextModifiers: { 'hero': 'in-hero', 'feature': 'in-feature' },
  sections: { header: 'header' },
  editHints: { icon: 'none', title: 'none' },
  structure: { … },
}

config.ts:386 today:

Hint: {
  block: 'hint',
  defaultDensity: 'compact',
  defaultElevation: 'sunken',
  modifiers: { hintType: { source: 'meta', default: 'note' } },
  contextModifiers: { hero: 'in-hero', feature: 'in-feature' },
  sections: hintSections,
  i18nEnums: { note: 'Note', warning: 'Warning', caution: 'Caution', check: 'Check' },
  metaFields: { hintType: { icon: { group: 'hint' } } },
  blocks: { header: { fields: ['hintType'], layout: 'bar' } },
  layout: { root: ['header'] },
}

structure: and editHints: are still valid RuneConfig fields (packages/transform/src/types.ts:209, :443), so this is not a vanished API — it is worse in the way that matters for a teaching page. The canonical example of how a rune is configured demonstrates the pre-SPEC-080 / SPEC-081 shape, and omits metaFields / blocks / layout entirely — the assembly model that replaced it. An author following the page writes a rune against the older mechanism, and nothing tells them otherwise.

Note what found it. Not a reader, not a test, not validate — a commit count.

Measured, not assumed

The measure was run over this repository, comparing each referring file's last commit against commits to its target since that point.

Embedded-source edges (snippet / file-ref / expand with a real path=), 263 invocations scanned:

PageTargetCommits since
runes/file-ref.mdpackage.json2
runes/snippet.mdpackages/runes/src/util.ts1
runes/codegroup.mdpackages/runes/src/tags/codegroup.ts1

Three, all trivial. The explicit-edge class is currently clean, which is a real and slightly inconvenient result: the strongest-typed edges are the ones with the least rot, because the rune pages get touched whenever the rune does.

Prose path mentions (a backticked packages/… or plugins/… path in body text) tell the opposite story — 13 of 35 resolvable edges, across 11 pages:

PageTargetCommits since
extend/rune-authoring/authoring-overview.mdpackages/runes/src/index.ts15
extend/rune-authoring/authoring-overview.mdpackages/runes/src/config.ts11
CLAUDE.mdpackages/lumina/contracts/structures.json6
extend/theme-authoring/creating-a-theme.mdpackages/lumina/test/css-coverage.test.ts5
extend/rune-authoring/content-models.mdplugins/learning/src/tags/recipe.ts5
extend/rune-authoring/authoring-overview.mdpackages/runes/src/tags/hint.ts3
… 7 more at 1–2

The rot is concentrated in the explanatory pages — the authoring guides, which explain mechanisms rather than demonstrate a single rune, and which therefore do not get touched when any one rune changes. That is precisely the set CLAUDE.md points agents at.

Plan source= edges, done work items whose source spec moved afterwards: 500 of 505, a 99% base rate. The class was measured, and it is rejected — D10.

Two conclusions shape the whole design.

The highest-yield edge class is the one nothing currently extracts, and the lowest-yield is the one the pipeline already models — a feature that only ranked snippet invocations would find almost nothing here today.

And an edge class earns inclusion by its base rate, not by existing. All four measured classes, side by side:

ClassNon-zeroOfRate
Embedded source32631%too sparse to rank
Prose mention133537%discriminates
Derived rune page949896%fires on everything — D15
Plan source=50050599%fires on everything — D10

A signal that fires on almost nothing and a signal that fires on almost everything are the same kind of useless. Only the second row supports a ranking, and which row a class lands in is not predictable from how well-typed its edges are — both rejected classes have more reliable edges than the one that works. Two of the four candidates died to this check, which is the argument for running it before writing an extractor rather than after.

The corpus is mostly invisible

Base rates describe the edges that exist. The harder number is how much of the corpus has no edge at all — 209 of 237 pages, because a page can describe a subsystem thoroughly without ever naming a file in it.

SectionPagesWith any edgeBlind
docs/56650
extend/35827
themes/12012
runes/11610106
blog/707

The first three rows are the problem. They are the explanatory prose — the authoring guides, the theme documentation, the conceptual docs/ pages — which is exactly where the measured rot lives, and 89 of their 103 pages are invisible to every automatic extraction rule.

site/content/extend/theme-authoring/overview.md is representative: it explains what a theme is, how the two-layer system works, and how themes are structured. It is about packages/transform/src/merge.ts and packages/runes/src/config.ts in every meaningful sense, and it names neither. No extractor recovers that, because the relationship is in the author's head and nowhere in the text.

The automatic classes cannot close this. A page's subject is not always recoverable from its words — which is the argument for letting an author state it, rather than for extracting harder. See D13.

runes/ is the exception that stays blind on purpose. It is the largest gap by count and the only section whose binding to source is mechanically derivable — and D15 records that the derived edges were built, measured at a 96% base rate, and rejected. Its 106 blind pages are not a gap this spec intends to close, by derivation or by hand.

What already exists

PieceState
Single-pass git log --format=%at --name-only scan → path → time mappackages/content/src/timestamps.ts:55 — the exact scan shape this needs
Shallow-clone detectiontimestamps.ts:37 — isShallowClone, already written
Git-root-relative path prefixingtimestamps.ts:123 — getGitRelativePrefix
Embedded-source edge extraction (path=, lines=)The shared reader — packages/runes/src/lib/read-file.ts
Symbol → resolved line rangeSPEC-131 phase 1
Entity reference extractionplugins/plan/src/scanner-core.ts:175 — extractRefs
source= / status= attribute parsingplugins/plan/src/diff.ts — parseTagAttributes
A ranked-report CLI with --format jsonplan status, the pattern to follow
An MCP tool surface returning structured findingsSPEC-043 — plugins/plan/src/mcp-bindings.ts, the binding pattern refrakt_stale follows
Advisory-severity finding categorySPEC-135 D10

Nothing here is new machinery. The git scan is a near-copy of one that already ships, and two of the four edge extractors already exist for other reasons.

One thing to change while copying the scan: it must pass --full-history. Measuring D16's base rate surfaced this. A pathspec makes git apply history simplification, which silently drops commits:

git log --name-only -- site/content/docs/cli/cli-overview.md
  → one commit, 2026-07-24

git log --full-history --name-only          (no pathspec)
  → five commits, most recent 2026-09-10

Both are "correct" git; they answer different questions. For this measure the difference is not academic — the referrer's last-changed time is one half of every score, so a simplified history inflates every edge out of that file. The same applies at the target end, in the other direction.

timestamps.ts may be unaffected in practice (it scans a directory for file mtimes, where a stale-but-present timestamp is tolerable), but the scan this spec copies is doing arithmetic on those numbers, so the flag is load-bearing here in a way it is not there. Check rather than assume when adapting it.

Proposal

The measure

For an edge from a referrer (a documentation file, or a region within it) to a target (a repository path, or a region within it):

staleness = commits touching the target since the referrer last changed

Commits, not elapsed days — D4. Zero when the referrer is newer than every change to its target. Zero when a matching SPEC-134 reviewed marker is present — D3.

Edge classes

ClassExtracted fromPrecisionStatus
Declareddocuments: in frontmatterHighest — the author stated itNew — D13
Embedded sourcesnippet / file-ref / expand path= (+ symbol=/lines=)High — exact path, and exact region under SPEC-131Extractor exists
Prose path mentionBackticked repo-relative path in body textLow — file-granular, and the paragraph may not be about the fileNew — D7
Described linkAn internal link with an adjacent description — page → pageMedium — 39% base rate, same band as proseNew — D16
Plan sourcesource= on a done work itemNone — 99% base rateRejected — D10
Derived rune pageplugin: + slug → conventional schema pathNone — 96% base rateRejected — D15
Bare linkPage → page with no descriptionToo low — a link is not a claimRejected — D16

Declared edges

A page states what it is about, once, in frontmatter:

---
title: Theme Overview
documents:
  - packages/transform/src/merge.ts
  - packages/runes/src/config.ts
---

Each entry is a repo-root-relative POSIX path, resolved through the same ProjectFiles containment as snippet path= — absolute paths and traversal escapes rejected identically. The resulting edges are ordinary edges: they rank like any other, and touching returns them like any other.

documents joins the declared members of Frontmatter (packages/content/src/frontmatter.ts:3) rather than living in its index signature — WORK-545's correction, and it means SPEC-126 generates the reference entry for it automatically.

Unlike every other class, a declared path that does not resolve is an error, not a skip — D14.

The command

refrakt stale                      # ranked report, all edge classes
refrakt stale --top 20             # bound the output (default 10) — D9
refrakt stale --class prose        # narrow to one edge class
refrakt stale --min 3              # floor on the commit count
refrakt stale --format json

Always exits zero — D1.

The MCP tool, which asks the question backwards

The CLI answers "what is stalest across the corpus?" — a survey, run occasionally. An agent editing code has a different and better-timed question: "I am about to change these files. What documents them?"

refrakt_stale serves both, and the second is the one that matters:

ArgumentQueryCaller
(none)The ranked report, as the CLIPeriodic review
touching: [paths]Every page whose edges point at those pathsAn agent mid-edit
since: <ref>The same, for everything changed since a git refA PR-scoped sweep

touching is an edge-index lookup, not a staleness measurement — there is no commit count involved, because the change has not happened yet. It returns structured rows (referring page, line, the edge's target, and whether a SPEC-134 reviewed marker is attached), never formatted text, per SPEC-135 D6.

The agent loop this is for:

agent edits packages/runes/src/config.ts
  → refrakt_stale { touching: ["packages/runes/src/config.ts"] }
  → extend/rune-authoring/authoring-overview.md:106 documents this file
  → agent opens that page and updates it in the same change

Output

Most likely stale, by commits to the target since the page last changed:

 15  extend/rune-authoring/authoring-overview.md  →  packages/runes/src/index.ts
     last changed 2026-07-09 · target 2026-09-16
     b40cbac per-type schema for playlist and track, resolving BUG-013
     40fb66d show a rune's resolved schema row in inspect, contracts and reference
     … 13 more

 11  extend/rune-authoring/authoring-overview.md  →  packages/runes/src/config.ts
     last changed 2026-07-09 · target 2026-09-16
     846d2d4 generate breadcrumb and timeline positions from a declared index
     … 10 more

The commit subjects are the report's whole value — D9. "11 commits" is a number; "generate breadcrumb and timeline positions from a declared index" is a person recognising that the page they wrote does not mention declared indexes.

Design decisions

D1 — This ranks. It never fails. No exit code, no build failure, no required check, not even an opt-in flag to make it failing. That is a stronger position than SPEC-135 D10 takes for SPEC-134's markers, and the difference is warranted: a reviewed marker that fires is a fact (this exact content changed after a human approved it), whereas a staleness count is a correlation. Eleven commits to config.ts does not mean the page is wrong, and a gate built on a correlation gets satisfied by touching the file.

The failure mode of getting this wrong is specific and fatal: the cheapest way to turn any edge green is to edit the referring page, which means a failing check would train people to make trivial edits to documentation to clear it — actively destroying the signal it was measuring. The measure only survives if nothing depends on it being zero.

D2 — Bulk application is legitimate here, and that is the whole difference from SPEC-134 D8. SPEC-134 forbids stamping markers in bulk, and is right to: its marker asserts a human read this, so bulk-stamping manufactures false evidence.

A staleness count asserts nothing about a human. It is derived, and it is true by construction whether or not anyone has ever looked at the edge. There is no claim to falsify, so there is no reason to restrict coverage — and coverage is the entire point, since the measured rot sits in pages nobody had flagged.

What SPEC-134's D8 warning does transfer is the noise concern, in a different shape: not false claims, but low precision. D9 is the answer to that.

D3 — A matching reviewed marker zeroes the edge. Where an invocation carries a SPEC-134 marker whose hash still matches, a human looked at that exact content after whatever commits the counter would report. The marker is strictly better evidence and supersedes the estimate.

This makes the two features compose in one direction: staleness ranks where to spend review effort, reviewed records that it was spent. The report is how an author decides what to stamp; a stamp removes it from the report until the content actually changes. Neither duplicates the other, and the cheap signal feeds the expensive one.

D4 — Commit count, not elapsed time. A file untouched for a year is not stale — it is stable, and its documentation is probably fine. A file that took fifteen commits last month while its guide sat still is the signal. Elapsed time measures the calendar; commit count measures divergence.

Churn (lines changed) is the tempting refinement and is rejected for now: a one-line change that flips a default invalidates more prose than a 200-line reformat, so weighting by size would rank the wrong way about as often as the right way.

D5 — Refuse on a shallow clone; never report zero. actions/checkout defaults to fetch-depth: 1. Under that, git log on any path returns one commit and every edge scores zero — the tool would confidently report a clean corpus, which is the silent-wrong class this repository keeps refusing to tolerate (SPEC-131, SPEC-135 D8).

isShallowClone already exists at timestamps.ts:37. The command detects it and exits with an explanation naming fetch-depth: 0, rather than producing a report. Same for "not a git repository" — this feature has no non-git fallback and should say so instead of degrading.

A refusal exits non-zero, and this does not contradict D1. D1 is about findings: no number of stale edges is a failure. Being unable to run at all is a different event — the tool produced no answer — and a CI step that silently succeeds while measuring nothing is the same silent-wrong this decision exists to prevent. Findings never fail; a broken invocation always does.

D6 — The referrer side resets on any edit, and this is a known, accepted false negative. A typo fix on a page updates its last-commit time and zeroes every edge leaving it. The measure is not "when was this claim last checked" but "when was this file last touched, for any reason". It will under-report.

Stated plainly because it bounds what the feature can claim: a zero is not evidence of freshness, only the absence of evidence of staleness. The report is a ranked list of suspects, never a certificate — and it is why nothing is allowed to gate on it (D1), and why SPEC-134's markers remain the instrument for references whose prose makes specific claims.

The partial mitigation is D8's region scoping, which narrows the referrer side from the file to the lines around the reference. It reduces the false-negative rate; it does not eliminate it, because editing a paragraph does not mean re-verifying it.

D7 — Prose path mentions are an edge class, and the highest-yield one. The measurement is unambiguous: every finding worth acting on came from prose, and the explicit-rune class produced three trivial hits. A version of this feature scoped to what the pipeline already extracts would have found nothing.

This is also the class with no existing extractor and the worst precision — a backticked path in a sentence like "formatting is owned by biome.jsonc" is a mention, not a documented claim. The design accepts that: with D1 removing any consequence from a false positive, and D9 bounding the output, a mention that ranks highly costs a reader three seconds.

Extraction is deliberately narrow — a backticked, repo-root-relative path that resolves to a file that exists today. Unbackticked paths, globs, and paths that no longer exist are all skipped. A path that no longer exists is a different and louder defect, and belongs to content:check-links, not here.

D8 — Scope to the region when the region is known; the file otherwise. The two ends are known independently. git log -L <start>,<end>:<path> counts only commits touching a given line range, and either end of an edge can use it whenever its region is resolvable:

Referrer regionTarget region
Embedded sourceThe invocation's own linesSPEC-131's resolved slice
Prose mentionThe paragraph containing the mentionNone — file-granular

The target side is what raises precision: config.ts is 2,782 lines, and eleven commits to the file says little about the Hint entry specifically.

The referrer side is the more valuable half, and the less obvious one. It is the direct mitigation for D6: scoping to the paragraph means a typo fix three sections away no longer zeroes the edge. That applies to prose mentions too — they have no target region, but the line their path appears on is perfectly well known. The class with the worst target precision gets the referrer-side fix regardless, which is fortunate, because it is the class that carries the value.

Phase 3 rather than phase 1 because -L is per-edge and cannot ride the single bulk scan, so the cost has to be measured before it could ever be the default; --precise until then.

D9 — Bounded, ordered output. A top-N, not a finding per edge. The corpus has hundreds of edges and most score zero. Emitting one diagnostic per non-zero edge would produce a wall that gets scrolled past — SPEC-126 D3's disabled-guard failure, reached by a different route.

Ten ranked entries with commit subjects attached is a thing a person reads on a Friday afternoon and acts on. That is the intended interaction, and the output format follows from it rather than from what the data structure makes convenient.

D10 — The plan source= class is measured and rejected. It was the most obvious edge class to include — the extractor already exists, the edges are explicit and typed, and "this work shipped before its spec last moved" sounds like exactly the question this spec is about.

The base rate kills it. 500 of 505 done work items with a source have a source entity that changed afterwards. A signal that fires on 99% of the population discriminates nothing; ranking within it would be ranking noise.

The base rate is also correct, which is the important part. Specs here are living documents — five of SPEC-130's work items show the pattern — and the implemented → shipped lifecycle (SPEC-049) guarantees at least one post-completion edit to every spec that ships, since status and released-in are both attributes on the spec file itself. "The spec moved after the work finished" is not drift here; it is the documented workflow.

Recorded as a decision rather than silently omitted, because the edge is tempting, cheap to build, and would have shipped on plausibility alone. The measurement is the whole argument, and it generalises: check a candidate edge class's base rate before building an extractor for it.

D11 — Its own command, not a refrakt validate tier. SPEC-135 D11 worries about command sprawl, so this needs a reason.

It is a hard dependency difference. validate runs against a corpus — content and config — and must work in the hosted, in-memory, SPEC-113 build where there is no .git at all. This feature is only git, refuses without it (D5), and cannot run in a pipeline. Folding it in would make validate behave differently depending on whether the build had a working tree.

The useful consequence: this spec is blocked on nothing. SPEC-134 waits on WORK-573 to make the diagnostic channel load-bearing, because a marker with no visible surface is a no-op. A command with its own stdout has no such dependency.

D12 — The MCP surface inverts the query, and that turns the feature from archaeology into prevention. The CLI is retrospective by construction: it finds rot that already happened, months after the commit that caused it, and only when someone remembers to look. That is worth having, and it is the weaker half.

Inverted — "what documents the file I am about to change?" — the same edge index answers at the only moment when fixing the docs is nearly free: while the author still has the change in their head, in the same commit, before the divergence exists. A staleness report is a list of debts. An impact lookup is how you avoid taking one on.

This is also the decision that makes agents the primary consumer rather than an afterthought. A human editing config.ts does not stop to run a CLI; an agent with a tool call in its loop does, reliably, every time — and this repository's documentation is already agent-edited, with CLAUDE.md prescribing a structured per-task workflow that has an obvious slot for one more step.

Two consequences follow. The edge index is the product, not the ranking. The counting is one consumer of it; touching is another, and does not use the git scan at all. Build it as an index that several queries read, not as a report generator with a lookup bolted on. And touching is exempt from D9's bound — it is already scoped by its input, so returning every match is correct where returning every stale edge would not be.

The honest limitation: this only helps an agent that asks. Nothing makes it ask, and that is the same "a command nobody invokes" problem the open questions carry — except that the remedy here is a line in CLAUDE.md rather than a scheduled job, which is considerably cheaper.

D13 — An author may declare edges, and the authoring cost is acceptable because it is paid once. This is the one place the spec asks for something to be written by hand, which sits awkwardly against its own selling point — every other class works on content nobody touches. The measurement is what justifies it: 209 of 237 pages have no edge at all, and 89 of those sit in the three sections where the rot was actually found. No extraction rule reaches them, because a page's subject is not always in its words.

The cost model is what separates this from SPEC-134 D8's prohibition on bulk stamping:

What it assertsWhen it must be rewritten
reviewed="a3f91c4e"A human read this version of this codeEvery time the code changes
documents: [path]This page is about that fileOnly if the page changes subject

A reviewed marker decays by design — that is the mechanism. A documents entry does not decay at all: it names what the page is about, which survives every edit to the code it describes. Written once when the page is created, it keeps producing edges for years. That is a genuinely different trade from stamping fifty hashes and re-stamping them each release, and it is why D8's reasoning does not carry over.

It is also the highest-precision class in the spec, for the obvious reason: an author who lists three files has chosen three files the page actually describes. There is no extraction heuristic to be wrong.

D14 — A declared path that does not resolve is an error; a mentioned one is skipped. D7 skips a backticked path that matches no file, because the likeliest explanation is that the extractor misfired on something that was never a path. Silence is right there.

A documents entry that matches no file has no such excuse. The author asserted the relationship, so a miss means the file was renamed, moved or deleted and the declaration was not updated — a fact worth reporting loudly. Without this, the mechanism designed to catch rot would quietly accumulate its own.

The asymmetry is the point: inferred edges fail quietly, declared edges fail loudly, and which one applies follows from whether a human made a claim.

D15 — Rune pages do not get derived edges. Measured, and rejected for the same reason as D10. The 116 rune pages are the largest blind section, and their binding to source looks mechanical enough to derive for free — which made this the most promising way to close the coverage gap without asking anyone to write documents: 116 times.

The derivation works. From a page's plugin: frontmatter and its slug, the conventional schema path (packages/runes/src/tags/{name}.ts, or plugins/{plugin}/src/tags/{name}.ts) resolves for 98 of 99 pages — and the single miss, runes/image-schemes.md, is a concept page rather than a rune, so the convention is exact over the set it applies to.

The resulting edges are worthless:

Commits since the page changedPages
165
219
39
41

96% non-zero, and no head to the distribution — a top ten would be ten arbitrary picks from the 9 pages tied at 3. Compare the prose class over the same corpus, which has a genuine tail: 15, 11, 6, 5, 5, then the mass.

The cause is the same as D10's, and equally benign. CLAUDE.md's rune checklist prescribes editing the schema, the config and the page together, so a rune page trailing its schema file by one or two commits is what a healthy workflow looks like, not evidence of rot.

Three things follow, and the third is the one that would have wasted the most time:

  • Region scoping cannot rescue it. D8's -L refinement narrows a target to the part the page is about; here the schema file is the rune, so there is no sub-region to narrow to.
  • The config edge would be worse. All 53 core rune pages resolve to one config.ts, which takes commits constantly — file-granularly they would fire in unison, permanently. It is viable only region-scoped, and even then it inherits this row's base rate.
  • documents: on rune pages is not the fallback. The class fails on the edge's signal, not on how the edge is produced. Declaring by hand what derivation would have produced automatically buys nothing, and it would take 116 pages of effort to buy it.

A correction worth recording, because it changed this decision's basis: the catalog does not know where a rune's schema lives. defineRune({ schema: hint }) holds an imported binding, not a path, so a catalog-driven derivation was never available — the filesystem convention is what carries it. And page slug is not rune name in at least 25 cases: tabs.md covers tab, tab-group and tab-panel; toc.md documents table-of-contents; icon.ts and badge.ts export a bare Schema and declare no rune name at all. Any future attempt here should start from the path convention, which is exact, rather than from the catalog, which cannot answer the question.

D16 — A link with a description is an edge; a bare link is not. The original table dismissed page → page as a non-goal on one line, and that was right about links and wrong about what sits beside them.

A link says go here. It asserts nothing, and site/content carries 748 internal links, the overwhelming majority of which are navigation, "see also", or an inline mention. Ranking those would produce the flat, headless distribution D10 and D15 reject.

But an overview page listing its children with one-line summaries is duplicating the target's content, and the duplicate is what rots. The link only identifies which target is being summarised; the description is the claim. Two shapes carry it, and both are structural rather than heuristic:

| [validate](/docs/cli/theme-tools#refrakt-validate) | Validate theme config and manifest |
- [xref](/runes/xref) — id-based sibling. Same `preview="drawer"` attribute.

A link in a table cell whose adjacent cell is prose, or a list item whose link is followed by a dash and prose. Nothing else counts — not a link in a paragraph, not a bare list item, not a nav entry.

Measured over site/content:

Described-link edges169 — 23% of the 748 internal links
Targets that failed to resolve0
Non-zero (target changed since referrer)67 = 39%

39% sits with the prose class this spec accepts (37%), and nowhere near the two it rejects (96%, 99%). The distribution has a head — 4, 3, 2, 2, 2, 2, 2 — and it is dominated by index → child edges, which is exactly where duplicated summaries live. That is the class behaving as intended rather than as a corpus-wide alarm.

The instance that produced this decision is the best one in the spec, because nobody was careless. v0.36.0 redefined refrakt validate from theme-authoring to site-authoring (WORK-578). The target page was updated thoroughly — docs/cli/theme-tools.md even carries a prominent "This changed in v0.36.0" migration hint — and WORK-579's criterion "Help text and docs describe theme validate as theme-authoring and refrakt validate as site-authoring" is checked off.

docs/cli/cli-overview.md still said "Validate theme config and manifest."

The link resolves. The anchor exists. The target is correct. Nothing was skipped, and no reviewer could have seen it — the stale text is on a different page from the change, and the only thing connecting them is a link that still works. The edge scores 1, from exactly the commit that invalidated it.

This is also the sharpest available argument for D12's touching. Asking refrakt_stale { touching: ["site/content/docs/cli/theme-tools.md"] } while doing WORK-578 would have named the overview page before the divergence existed — where the ranked report would only have surfaced it later, if someone ran it.

Targets are content files, so nothing new is needed. Git tracks a .md the same way it tracks a .ts, and the measure is unchanged. The one difference worth stating: a described-link edge points within the corpus, so editing the target moves that file's own referrer timestamp too. That is correct — a page whose summary was updated alongside its target scores zero, which is the behaviour wanted.

Deliberately not derived from the rendered link graph. The extraction reads Markdoc source, not resolved hrefs, so {% ref %} / {% xref %} entity links stay out. They resolve through the registry rather than carrying an authored description, and the row above keeps them a non-goal on their own merits.

Non-goals

  • Verifying that documentation is correct. Same impossibility as SPEC-134: the claim lives in a human's head. This measures divergence between two files' commit histories and nothing more.
  • Failing anything on a finding. D1. Not a build, not a PR check, not an opt-in strict mode. A refusal to run is separate and does exit non-zero — D5.
  • In-pipeline diagnostics. No PipelineWarning, no dev-server output, no entry in the editor validation rail. Hosted builds have no git (D11), and a correlation does not belong beside a page.
  • A rune. Nothing renders. There is no {% staleness %}.
  • Content-to-content edges. {% ref %} / {% xref %} between pages score almost everything as stale, because content changes constantly. Excluded until someone demonstrates a use.
  • Auto-fixing, or auto-stamping reviewed. A tool that clears its own signal is SPEC-134's "automatic re-stamping in CI" non-goal wearing a different hat.
  • Weighting by churn, blame, or authorship. D4. Revisit only with evidence that the plain count mis-ranks.
  • Globs in documents. packages/runes/src/tags/*.ts is the obvious ask and the obvious trap: it expands to an edge set that is stale whenever any member moves, so a page declaring it fires permanently. That is the 99% base rate of D10, invited in by hand. Literal paths only until someone shows a case the ranking survives.
  • Cascading documents through _layout.md. Frontmatter cascades in refrakt, and this field deliberately does not. A subtree declaration would put the same edge on every page beneath it, so one file moving lights up all 27 pages of a section at once — noise that buries the three pages that individually declared it. Per page, where the claim is specific enough to be actionable.

Acceptance Criteria

  • refrakt stale reports a ranked list of edges, ordered by commits to the target since the referrer last changed
  • The git scan is a single git log --name-only pass over the repository, not one git log invocation per edge
  • The scan runs with --full-history and no pathspec, so history simplification cannot drop commits from a file's history
  • A test pins a file whose simplified and full histories differ, asserting the scan reports the full one
  • Embedded-source edges are extracted from snippet, file-ref and expand path= attributes
  • Prose path mentions are extracted from backticked repo-relative paths that resolve to an existing file
  • A backticked path that does not resolve to an existing file is skipped, not reported
  • Described-link edges are extracted from internal links carrying an adjacent description — a table cell whose neighbouring cell is prose, or a list item whose link is followed by a dash and prose
  • A link with no adjacent description produces no edge, covered by a test over a nav list and an inline paragraph mention
  • A described link whose target does not resolve to a content page is skipped, not reported
  • Described-link targets resolve through both <path>.md and <path>/index.md, with any #anchor stripped first
  • {% ref %} / {% xref %} entity links produce no described-link edges — extraction reads Markdoc source, not resolved hrefs
  • documents is a declared member of the Frontmatter interface, not read through its index signature
  • Each documents entry produces an edge that ranks and answers touching identically to an extracted one
  • documents entries resolve through ProjectFiles, rejecting absolute paths and traversal escapes as snippet path= does
  • A documents entry that resolves to no existing file is reported as an error, naming the page and the entry
  • A documents entry set in a _layout.md does not cascade to pages beneath it
  • The frontmatter reference documents documents, generated from the schema
  • An edge whose referrer is newer than every change to its target scores zero and is omitted
  • An invocation carrying a matching SPEC-134 reviewed marker scores zero regardless of commit count
  • Output is bounded by --top, defaulting to 10, and each entry lists commit subjects for the target's changes
  • refrakt stale exits zero whatever it finds, including when every edge in the corpus is stale
  • A refusal (shallow clone, non-git tree) exits non-zero, distinguishing "could not measure" from "measured, nothing wrong"
  • A shallow clone is detected and refused with a message naming fetch-depth: 0, rather than reporting an empty result
  • A non-git working tree is refused with its own message, not treated as a clean corpus
  • --class, --min and --format json behave as specified
  • A test fixture reproduces the measured case: a page referencing a file that has since taken N commits ranks above one referencing an unchanged file
  • --precise scopes both ends to line ranges via git log -L where a region is resolvable, and falls back to file granularity where it is not
  • The report footer states each class's base rate — how many edges of that class were non-zero out of how many scanned
  • A refrakt_stale MCP tool with no arguments returns the ranked report as structured findings, not formatted text
  • refrakt_stale { touching: [paths] } returns every page whose edges point at those paths, with referring page, line, target, and whether a reviewed marker is attached
  • touching runs without the git scan and returns results for a path with no commit history — including a file created in the working tree and never committed
  • touching returns every match, unbounded by --top
  • refrakt_stale { since: <ref> } resolves the changed paths from that ref and answers as touching would
  • The edge index is a shared module that the ranking, touching and since queries all read, with no query owning it
  • CLAUDE.md documents the touching call as a step in the per-task workflow
  • Docs state that a zero score is the absence of evidence of staleness, not evidence of freshness

Approach

Four phases. Phase 1 is self-contained and shippable alone.

  1. The edge index and the report. The index as a standalone module (D12), the single-pass git scan (adapted from timestamps.ts), embedded-source edge extraction from the existing reader, ranking, and the refrakt stale command with --top / --min / --format json. Plus D5's refusals, which are load-bearing from the first commit. This phase will find almost nothing in this repository — see the measurement — and that is the correct order anyway: it establishes the index, the scan and the output shape against the class whose extraction is already trustworthy.
  2. Coverage: prose extraction and declared edges. The two classes that between them reach the pages that matter — prose for the ones that name files (D7), documents for the 89 explanatory pages that do not (D13). The frontmatter half is the smaller job by far (a declared field, a resolver, an error path) and should land first, because it needs no precision tuning and makes the phase useful while the extraction rule is still being argued about. The first run over site/content is the real acceptance test for the prose half: if the top ten are not things a maintainer agrees are worth reading, the rule is wrong and the phase is not done.
  3. The MCP tool, and the touching query. Both modes, plus the CLAUDE.md line that puts the call in the per-task loop. This is the phase where the feature stops being retrospective (D12), so it is not a tail — it is the payoff, and it is deliberately placed after phase 2 only because an impact lookup that knows about snippet invocations but not about the guides is a lookup that misses the pages most worth updating.
  4. Region scoping (--precise). git log -L on both ends where SPEC-131 makes a region resolvable. Measure the cost before deciding whether it can ever be the default. Last because it sharpens a signal the earlier phases have to prove is worth sharpening.

Budget the extraction rule, not the arithmetic. The counting is a map lookup and a subtraction. What decides whether anyone runs this a second time is whether phase 2's top ten are worth reading — which is a question about which prose mentions count as claims, and is answered by trying it on a real corpus and throwing away rules that surface noise.

Build the index so phase 3 does not have to rewrite it. The temptation in phase 1 is a function that walks content, scores as it goes, and prints. That shape cannot answer touching, because the query runs in the opposite direction and without the git scan at all. Extract edges into an addressable index first and let the ranking be one reader of it — the cost is an afternoon in phase 1 and a rewrite avoided in phase 3.

Open questions

  • Who reads the report, and when? Largely answered by D12, and worth recording that it was the spec's weakest point before the MCP surface existed. The retrospective report still has no gate and no diagnostic surface by construction (D1, D11), so as a report it remains a command someone must choose to run — SPEC-126's failure mode, for the fourth time here. What changed is that the feature no longer depends on that: touching is invoked by an agent mid-task, needs no scheduling, and catches the divergence before it exists. The residual question is narrower — whether the periodic survey earns a scheduled job on top, or whether prevention at edit time makes it a tool you reach for only when inheriting an unmaintained corpus.
  • Does a CLAUDE.md line actually change agent behaviour? D12 leans on it, and it is an assumption, not a result. The per-task workflow already prescribes several structured steps and they are followed, which is weak evidence in favour. Phase 3 should check rather than assume: if agents do not call touching unprompted, the remedy is a hook or a plan update side effect, not a more emphatic sentence.
  • Does the prose class hold its base rate as the corpus grows? 13 of 35 is healthy, but 35 edges across 11 pages is a small sample, and the rate is a property of how often this repository edits guides versus code — not a constant. If it drifts toward the plan class's 99%, the ranking degrades into a list of everything and the feature needs the region scoping of phase 4 to stay useful. Worth re-measuring, not assuming, and worth refrakt stale printing its own base rate in the report footer so the degradation is visible rather than inferred.
  • Should prose extraction honour an opt-out? A page that legitimately mentions many paths without documenting them (CLAUDE.md's monorepo map, for instance) will rank persistently. Frontmatter opt-out is the easy answer and also the easy way to make the feature disappear one page at a time. Prefer fixing the extraction rule first and revisit only if a page is genuinely miscategorised.
  • Is the top-N the right bound, or should it be a threshold? --top 10 is a fixed budget regardless of corpus health; --min N is a fixed bar regardless of corpus size. The report currently offers both and defaults to the budget. Whether that is right is a question about how the report is consumed, which the first open question has to settle first.

References

  • SPEC-134 — the precise instrument this feeds; D8 (no bulk stamping), which D2 distinguishes, and D7 (a prompt, not a failure), which D1 extends
  • SPEC-131 — symbol addressing, which makes D8's region scoping possible
  • SPEC-135 — the advisory-finding category (D10), the sibling-commands argument (D11) this spec answers to, and D6's rule that an MCP tool returns findings rather than a rendered report
  • SPEC-043 — the MCP server refrakt_stale joins; plugins/plan/src/mcp-bindings.ts is the binding pattern
  • SPEC-113 — the hosted, git-less build that keeps this out of the pipeline
  • SPEC-126 — guards that nothing runs (the open question above is its fourth instance), and the generator that turns a declared documents into its own reference entry
  • BUG-020 — prose invalidated by an ordinary edit, found by a human rather than a check
  • packages/content/src/timestamps.ts — the single-pass git scan and isShallowClone, both adapted rather than rewritten
  • packages/runes/src/lib/read-file.ts — the shared reader carrying path= / lines=
  • packages/content/src/frontmatter.ts — the Frontmatter interface documents joins; created / modified are the precedent for a field that overrides what git reports
  • plugins/plan/src/scanner-core.ts — extractRefs, the plan-edge index
  • site/content/extend/rune-authoring/authoring-overview.md — the instance in Problem