diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 046ec01632..466c4e2ecb 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -58,7 +58,7 @@ registry to fold it back onto — the override *is* its governance. | Status | Meaning | |---|---| -| `live` | Has a runtime consumer. Cite it in `evidence` as `file:line`; for another repo's path, prefix the realm — `objectui: packages/app-shell/…` (see below). | +| `live` | Has a runtime consumer. Cite it in `evidence`, preferably anchored to the consuming symbol — `file#symbol` — with `file:line` as an optional convenience; for another repo's path, prefix the realm — `objectui: packages/app-shell/…` (see below). | | `experimental` / `planned` | Declared, intentionally not enforced yet. Also read from a spec `.describe()` marker like `[EXPERIMENTAL — not enforced]`. | | `dead` | Parsed, no consumer. Tracked for **enforce-or-remove** (ADR-0049). | @@ -74,6 +74,23 @@ checkout. Prose around the paths is fine and encouraged — `packages/spec/src/s (mergeActionsIntoObjects stable-sorts each group)` resolves the path and ignores the parenthetical. +**Anchor the consuming symbol, not (only) a line** (#12516). A citation may be +written `packages/…/file.ts#dispatchFlowAction` — the proof-ref `#` convention +applied to the citation grammar — and the gate then verifies the symbol is +still in the cited file (`✗`, exit 1 when it is not; a malformed anchor — not +one identifier — also fails, the `verifiedAt` bad-date asymmetry one field +over). Prefer this form. A **line number rots in range**: the file exists, the +line is inside it, the file names the key, and the consumer has simply moved to +a different line of the same file — every check passes and the pointer is +wrong. Measured: the two `action.json` entries repointed with fresh line +numbers on 2026-08-25 had both drifted that way by 2026-08-26, because their +1670-line file is actively edited — the more precisely a line is cited, the +faster it rots. A symbol moves *with* the consumer, so the anchor survives +exactly that movement, and when the consumer is renamed or deleted the gate +goes red — a direction a stale line can never produce. A line may still ride +along (`file.ts#symbol:150`, either order): it stays a human convenience, +bounded by the past-EOF check, and the symbol is the load-bearing half. + **A repo-local path that does not resolve FAILS CI** (`✗`, exit 1 — since #5623; it was a non-failing `⚠` before, calibrated for the parser bug described in the note below rather than for the parser we have). A `live` verdict *is* its diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index f3f0741b22..c4667678d9 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -43,9 +43,9 @@ }, "target": { "status": "live", - "verifiedAt": "2026-08-25", - "evidence": "packages/runtime/src/action-execution.ts:725 (type:'flow' server dispatch — automation.execute(action.target, …), with :718 rejecting an unknown flow name by that same value); packages/runtime/src/action-execution.ts:472 (headlessActionTypeError names the target the client-dispatched types go to instead)", - "note": "URL/script/flow/endpoint + ${param}/${ctx} interpolation. 2026-08-25: REPOINTED — the evidence cited packages/runtime/src/http-dispatcher.ts, which has 0 occurrences of `target`; the action consumer was extracted into domains/actions.ts + action-execution.ts and http-dispatcher.ts now only delegates (handleActions → handleActionsRequest at :1969-1970). The file still existed and the citation carried no line, so nothing in the gate could see it." + "verifiedAt": "2026-08-28", + "evidence": "packages/runtime/src/action-execution.ts#dispatchFlowAction (type:'flow' server dispatch — automation.execute(action.target, …) runs inside it, after flowIsUnknown rejects an unrecognized flow name by that same value); packages/runtime/src/action-execution.ts#headlessActionTypeError (names the target the client-dispatched types go to instead)", + "note": "URL/script/flow/endpoint + ${param}/${ctx} interpolation. 2026-08-25: REPOINTED — the evidence cited packages/runtime/src/http-dispatcher.ts, which has 0 occurrences of `target`; the action consumer was extracted into domains/actions.ts + action-execution.ts and http-dispatcher.ts now only delegates. The file still existed and the citation carried no line, so nothing in the gate could see it. 2026-08-28: RE-ANCHORED (#12516) — the 08-25 repoint wrote line numbers (:725/:718/:472) and all three drifted IN RANGE within a day (the lines now hold the FlowActionRefusal brand block and disabledActionRefusal's docblock); the citation now anchors the consuming symbols, which move with the consumer." }, "body": { "status": "live", @@ -109,9 +109,9 @@ }, "requiredPermissions": { "status": "live", - "verifiedAt": "2026-08-25", - "evidence": "packages/runtime/src/domains/actions.ts:172-180 (ADR-0066 D4 dual-surface gate — resolves the action's declared requiredPermissions and rejects 403 when the caller's systemPermissions don't cover them); packages/runtime/src/action-execution.ts (resolveRouteActionDeclaration supplies the declaration the gate reads)", - "note": "ADR-0066 D4 dual-surface action gate. Server is source of truth: handleActionsRequest rejects (403) when the caller's systemPermissions don't cover action.requiredPermissions; objectui ActionRunner derives the same UI hide/disable. Unit-proven in packages/runtime/src/http-dispatcher.test.ts + packages/spec/src/ui/action.test.ts (both re-measured 2026-08-25 and still naming the key). 2026-08-25: REPOINTED — the evidence cited packages/runtime/src/http-dispatcher.ts, which has 0 occurrences of `requiredPermissions` since the actions domain was extracted into domains/actions.ts. Note the split the repoint exposes: the TEST kept the name while the source lost it, so the note's pointer stayed true while the evidence pointer rotted." + "verifiedAt": "2026-08-28", + "evidence": "packages/runtime/src/action-execution.ts#actionPermissionError (ADR-0066 D4 dual-surface gate — reads actionDef.requiredPermissions and returns the 403 message when the caller's systemPermissions don't cover them); packages/runtime/src/domains/actions.ts#handleActionsRequest (the /actions route's D4 call site — rejects 403 via that gate); packages/runtime/src/action-execution.ts#resolveRouteActionDeclaration (supplies the declaration the gate reads)", + "note": "ADR-0066 D4 dual-surface action gate. Server is source of truth: handleActionsRequest rejects (403) when the caller's systemPermissions don't cover action.requiredPermissions; objectui ActionRunner derives the same UI hide/disable. Unit-proven in packages/runtime/src/http-dispatcher.test.ts + packages/spec/src/ui/action.test.ts (both re-measured 2026-08-25 and still naming the key). 2026-08-25: REPOINTED — the evidence cited packages/runtime/src/http-dispatcher.ts, which has 0 occurrences of `requiredPermissions` since the actions domain was extracted into domains/actions.ts. Note the split the repoint exposes: the TEST kept the name while the source lost it, so the note's pointer stayed true while the evidence pointer rotted. 2026-08-28: RE-ANCHORED (#12516) — the 08-25 repoint wrote :172-180 and it drifted IN RANGE within a day (those lines now hold path-part parsing and the invalidBody constructor; the actual key read lives in actionPermissionError, single-sourced in action-execution.ts and called from handleActionsRequest). Symbol anchors move with the consumer." }, "disabled": { "status": "live", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 5e9931e0cc..8409ccd4c1 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -62,8 +62,31 @@ // file that no longer mentions the property, and `mapping.fieldMapping` cited a // range ending 3 lines past the end of `import-mapping.ts`. A range is bounded // by its END. The complementary case — a consumer that moved WITHIN its file — -// is out of reach here by construction and is tracked separately; the ✗ text -// below says why its obvious detector is not obviously right. +// is out of reach here by construction; the ✗ text below says why its obvious +// detector is not obviously right, and SYMBOL ANCHORS (next paragraph) are the +// durable citation form that class cannot rot. +// +// SYMBOL ANCHORS (#12516): a citation may anchor the consuming SYMBOL instead +// of (or beside) a line — `path/to/file.ts#dispatchFlowAction`, the proof-ref +// `#` convention applied to the citation grammar. The in-range residual above +// is not theoretical: the two `action.json` entries PR #12214 repointed with +// fresh line numbers on 2026-08-25 had both drifted by 2026-08-26 — the cited +// lines were in range, the file named the key, and the consumers had simply +// moved to other lines of the same 1670-line file, so every check stayed +// green while both pointers were wrong. A symbol moves WITH the consumer, so +// the anchor survives exactly that movement; when the consumer is renamed or +// deleted the symbol is gone from the file and the gate goes red — a +// direction a stale line can never produce, since a drifted line is still "a +// line the file has". The check is text-level on purpose (the same file reads +// the key-mention check already does; no TS program in the gate's CI path). +// Its measured limit: a symbol that survives while its body stops reading the +// key is out of reach at text level, and the line-window detector that would +// chase it was censused BEFORE being switched on (the #11457 discipline) at +// 117-173 misses out of 298 live line citations — a population that cannot be +// told apart from matcher noise without re-measuring every entry, i.e. the +// 48-of-227 era again. The file-level key-mention check stays that residual's +// backstop, and anchors are opt-in per citation: adoption is the repair path, +// entry by entry, as call graphs get re-closed. // // PRODUCER-SIDE EVIDENCE (`producer`, #4837): `live` means AUTHORING the // property changes runtime behaviour. A consumer that reads the property is @@ -135,7 +158,7 @@ import { type VerificationEntry, type VerificationReport, } from './verification.mts'; -import { checkCitationLines, checkEvidence, countLines, type EvidenceScan } from './evidence.mts'; +import { checkCitationLines, checkEvidence, checkEvidenceAnchors, countLines, type EvidenceScan } from './evidence.mts'; import { KEY_MENTION_GUIDANCE, findUnanchoredCitations, @@ -459,6 +482,13 @@ const report: any = { keyMentionExempt: 0, // ...of which this many are recorded in the shrink-only baseline keyMentionUnanchored: [] as string[], // ...and this many are NOT — FAILS the gate keyMentionStale: [] as string[], // a baseline row whose pair now anchors — also FAILS + // The SYMBOL half of a citation (#12516). A line citation rots IN RANGE when + // the consumer moves within its file — every check above stays green. An + // anchored citation (`path#symbol`) survives that movement and goes red when + // the symbol leaves the file. + anchorsChecked: 0, // resolvable local `path#symbol` anchors asked + anchorsUnresolved: [] as string[], // ...of which this many name a symbol the file does not contain — FAILS + anchorsMalformed: [] as string[], // an anchor that is not one identifier — also FAILS (silent-degrade guard) }; // Every classified entry, for the `verifiedAt` fold below. Collected during the @@ -510,6 +540,27 @@ function collectOutOfRange(scan: EvidenceScan, label: string): void { } } +/** + * Resolve a scanned pointer's `path#symbol` anchors against the cited files. + * `producer` and `evidence` both come through here, for the reason + * `collectOutOfRange` states: an anchor names a SYMBOL, not the key, so — + * unlike the key-mention check — it applies to a call-site pointer exactly as + * it applies to a consumer pointer, and a standard that held for one and not + * the other would leave the weaker one as where a renamed symbol hides. + */ +function collectAnchorFindings(scan: EvidenceScan, label: string): void { + const anchors = checkEvidenceAnchors(scan, contentOf); + report.anchorsChecked += anchors.checked.length; + for (const a of anchors.unresolved) { + report.anchorsUnresolved.push(`${label} → ${a.path}#${a.symbol}`); + } + for (const a of anchors.malformed) { + report.anchorsMalformed.push( + `${label} → ${a.path}#${a.symbol} — an anchor must be one identifier ([A-Za-z_$][A-Za-z0-9_$]*)`, + ); + } +} + function classify(type: string, path: string, status: string, led: any, cat: any) { cat.classified++; cat.byStatus[status] = (cat.byStatus[status] || 0) + 1; @@ -532,6 +583,7 @@ function classify(type: string, path: string, status: string, led: any, cat: any const pv = checkEvidence(led.producer, (p) => existsSync(join(repoRoot, p))); for (const miss of pv.missing) report.producerMissing.push(`${type}/${path} → ${miss}`); collectOutOfRange(pv, `${type}/${path} [producer]`); + collectAnchorFindings(pv, `${type}/${path} [producer]`); } if (status === 'live' && led?.evidence) { // Extract every repo-rooted path the evidence claims and resolve the ones @@ -544,6 +596,7 @@ function classify(type: string, path: string, status: string, led: any, cat: any report.evidenceMissing += ev.missing.length; for (const miss of ev.missing) report.staleEvidence.push(`${type}/${path} → ${miss}`); collectOutOfRange(ev, `${type}/${path}`); + collectAnchorFindings(ev, `${type}/${path}`); // The within-file question (#11457), asked of `evidence` ONLY — never of // `producer`. That asymmetry is deliberate and is not the one // `collectOutOfRange` deliberately refuses just above: a line bound applies @@ -822,6 +875,15 @@ const failed = // false-positive era to calibrate against — a line past EOF is arithmetic, and // the two shipped instances it found were both real (#11210). report.citationsOutOfRange.length > 0 || + // ...and the SYMBOL half (#12516). Red rather than ⚠ from day one, on the + // zero-census argument the orphan-proof flip states: the anchor grammar was + // introduced by the same change that measured ZERO pre-existing `path#symbol` + // tokens across every ledger evidence/producer string, so the gate starts + // green and only a NEW anchor can red it. A malformed anchor fails for the + // `verifiedAt` reason — parsed to nothing, it silently disables the check + // for exactly that citation. + report.anchorsUnresolved.length > 0 || + report.anchorsMalformed.length > 0 || // ...and the within-file half (#11457). Red rather than ⚠ on the strength of // the census that designed it: the signal was measured over the whole ledger // BEFORE it was switched on, the seven real rots it found were repaired, the @@ -887,9 +949,12 @@ if (asJson) { ' it survives review because it LOOKS precise, and the next agent re-verifying the\n' + ' entry follows it, finds nothing, and has to rebuild the call graph from scratch.\n\n' + ' Repairs, same three as a missing file and picked the same way:\n' + - ' • the consumer MOVED (inside this file or out of it) → repoint at the real line,\n' + - ' MEASURED — open the file and read it, do not shift the number by the diff — and\n' + - ' stamp `verifiedAt` while you have the call graph open;\n' + + ' • the consumer MOVED (inside this file or out of it) → repoint, MEASURED — open\n' + + ' the file and read it, do not shift the number by the diff — and stamp\n' + + ' `verifiedAt` while you have the call graph open. Prefer anchoring the\n' + + ' consuming SYMBOL (`path#symbol`, #12516) over a fresh line: the line you\n' + + ' write today rots the same way this one did, the symbol moves with the\n' + + ' consumer;\n' + ' • the consumer moved to ANOTHER repo → attribute it with a realm marker; those\n' + ' are counted, never resolved, and never bounded here;\n' + ' • the consumer is GONE → the verdict is not `live` any more. Re-classify under\n' + @@ -897,9 +962,51 @@ if (asJson) { ' A RANGE (`:12-34`) is bounded by its END: a range whose tail is past EOF overruns\n' + ' the file even when its head is inside.\n\n' + ' A consumer that moved WITHIN the file it is cited to leaves every line in range,\n' + - ' so nothing here fires — that complementary case is now the key-mention check\n' + - ' below, which the #11457 census designed rather than bolted on (a naive match on\n' + - ' the authoring key reports the whole camelCase→snake_case class as rot).', + ' so nothing here fires — the key-mention check below catches the file-level form\n' + + ' (the #11457 census designed it rather than bolted it on: a naive match on the\n' + + ' authoring key reports the whole camelCase→snake_case class as rot), and a\n' + + ' `path#symbol` anchor (#12516) is the citation form the line-level form cannot\n' + + ' rot at all.', + ); + } + // The SYMBOL half (#12516). Two numbers, same discipline, same reason as the + // three above: "all resolved" over zero anchors is what a degraded parser + // prints too. + console.log( + `symbol anchors: ${report.anchorsChecked} pointer(s) written \`path#symbol\`, ` + + `${report.anchorsChecked - report.anchorsUnresolved.length} naming a symbol the cited file contains` + + (report.anchorsUnresolved.length ? `, ${report.anchorsUnresolved.length} UNRESOLVED` : '') + + (report.anchorsMalformed.length ? `; ${report.anchorsMalformed.length} MALFORMED` : '') + '.', + ); + if (report.anchorsUnresolved.length) { + console.log(`\n✗ ${report.anchorsUnresolved.length} anchored citation(s) name a symbol the cited file does not contain:`); + report.anchorsUnresolved.forEach((s: string) => console.log(` ${s}`)); + console.log( + '\n An anchored citation survives the drift that rots a line — the symbol moves\n' + + ' WITH the consumer — so this firing means the consumer itself left: renamed,\n' + + ' deleted, or promoted out of the file. That is the strongest rot signal this\n' + + ' gate has, and the one a line citation can never produce (a drifted line is\n' + + ' still "a line the file has").\n\n' + + ' Repairs, same three as ever, picked by re-reading the code:\n' + + ' • the consumer was RENAMED or moved within this repo → re-anchor at the real\n' + + ' symbol, MEASURED — read the declaration, confirm it still reads the key —\n' + + ' and stamp `verifiedAt` while the call graph is open;\n' + + ' • the consumer moved to ANOTHER repo → attribute it with a realm marker;\n' + + ' foreign anchors are counted, never resolved;\n' + + ' • the consumer is GONE → the verdict is not `live` any more. Re-classify\n' + + ' under ADR-0049 enforce-or-remove rather than re-anchoring at a plausible\n' + + ' survivor.', + ); + } + if (report.anchorsMalformed.length) { + console.log(`\n✗ ${report.anchorsMalformed.length} malformed anchor(s) — not one identifier:`); + report.anchorsMalformed.forEach((s: string) => console.log(` ${s}`)); + console.log( + '\n An anchor the checker cannot judge is a citation the gate silently stops\n' + + ' holding to the symbol standard — the `verifiedAt` malformed-date asymmetry,\n' + + ' one field over. Write `path/to/file.ts#oneIdentifier`; for a dotted member\n' + + ' read (`IDENT.MEMBER`), anchor the declaring symbol and put the member in the\n' + + ' prose.', ); } // The within-file half. Two numbers again, and for the third time the same @@ -1201,7 +1308,8 @@ if (asJson) { '\n✓ every governed-type property at the walk\'s one-level granularity is classified, every ' + 'registered type is governed or explicitly pending, no ledger row outlives its property, ' + 'every container inheritance is declared, every `live` entry\'s repo-local evidence path ' + - 'resolves, every `path:NNN` citation names a line that file actually has and every cited ' + + 'resolves, every `path:NNN` citation names a line that file actually has, every ' + + '`path#symbol` anchor names a symbol its file contains, and every cited ' + 'file names the property it is evidence for (or is a recorded exemption), all bound ' + 'high-risk proofs resolve, every dogfood `@proof:` tag on disk is registered in ' + 'proof-registry.mts, and the README state table carries a row ' + diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index 309d5fcc7c..14ed1cafb3 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -210,6 +210,93 @@ describe('check:liveness — evidence pointers (#5623)', () => { }); }); +// #12516 — the SYMBOL half of a citation. A line citation rots IN RANGE: the +// consumer moves within its file, the file exists, every cited line is inside +// it, the file still names the key — all three earlier checks stay green and +// the pointer is wrong (measured: both `action.json` entries repointed with +// fresh lines on 2026-08-25 had drifted by 2026-08-26). A `path#symbol` anchor +// moves WITH the consumer; the rot that remains — the symbol renamed, deleted, +// or promoted out of the file — is exactly what these cases replay, against the +// REAL gate via `--ledger-root`, for the #5623 reason: the grading lives in +// check-liveness.mts and a helper test cannot pin it. +describe('check:liveness — symbol anchors (#12516)', () => { + let tmp: string; + + beforeAll(() => { + tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-anchor-')); + }); + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + it('FAILS when an anchored symbol is gone from a file that still exists and still names the key', () => { + // The measured rot event replayed at anchor granularity: the consumer + // (dispatchFlowAction) moves out / is renamed. Exactly one cause: the file + // resolves, the citation names no line, and action-execution.ts genuinely + // names `target` — so neither the existence check, the line bound, nor the + // key-mention check can be the reason for the exit code. + const root = path.join(tmp, 'symbol-gone'); + cpSync(LEDGERS, root, { recursive: true }); + setEvidence(root, 'action', 'target', 'packages/runtime/src/action-execution.ts#dispatchFlowActionMovedAway'); + + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('anchored citation(s) name a symbol the cited file does not contain'); + expect(output).toContain('action/target → packages/runtime/src/action-execution.ts#dispatchFlowActionMovedAway'); + // ✗, not ⚠ — same grading argument as every citation check before it. + expect(output).toMatch(/✗ 1 anchored citation\(s\)/); + }); + + it('FAILS on a malformed anchor instead of silently dropping the standard', () => { + const root = path.join(tmp, 'malformed'); + cpSync(LEDGERS, root, { recursive: true }); + setEvidence(root, 'action', 'target', 'packages/runtime/src/action-execution.ts#dispatch-flow (prose naming target)'); + + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('malformed anchor(s) — not one identifier'); + expect(output).toContain('action/target → packages/runtime/src/action-execution.ts#dispatch-flow'); + }); + + it('stays GREEN on the drifted BEFORE-state — the honest residual this grammar exists to retire', () => { + // The exact evidence string `action.target` carried between 2026-08-25 and + // this change: every cited line is in range, the file names the key, and + // the lines hold the wrong code. The gate cannot see that, BY CONSTRUCTION + // — text cannot tell "the consumer" from "plausible code at the address" — + // which is why the repair is anchor ADOPTION, not a smarter line check + // (the #12516 census measured 117-173 of 298 line citations failing a + // key-proximity window — indistinguishable from matcher noise without + // re-measuring every entry, i.e. the 48-of-227 era again). This case pins + // the boundary so the red case above stays attributable to the anchor. + const root = path.join(tmp, 'before-state'); + cpSync(LEDGERS, root, { recursive: true }); + setEvidence( + root, + 'action', + 'target', + "packages/runtime/src/action-execution.ts:725 (type:'flow' server dispatch — automation.execute(action.target, …), with :718 rejecting an unknown flow name by that same value); packages/runtime/src/action-execution.ts:472 (headlessActionTypeError names the target the client-dispatched types go to instead)", + ); + + const { status, output } = runGate(root); + expect(status, output).toBe(0); + }); + + it('prints the anchor count and how many resolve, equal on a green run', () => { + // The #5623 two-number discipline, fourth application: "all resolved" over + // zero anchors is what a degraded parser prints too. + const root = path.join(tmp, 'green'); + cpSync(LEDGERS, root, { recursive: true }); + const { status, output } = runGate(root); + expect(status, output).toBe(0); + const line = output.split('\n').find((l) => l.startsWith('symbol anchors:')) ?? ''; + const m = /symbol anchors: (\d+) pointer\(s\) written .*?, (\d+) naming a symbol the cited file contains/.exec(line); + expect(m, line).not.toBeNull(); + // The two #12516 repoints are the day-one anchored population. + expect(Number(m![1])).toBeGreaterThanOrEqual(2); + expect(m![2]).toBe(m![1]); + expect(line).not.toContain('UNRESOLVED'); + expect(line).not.toContain('MALFORMED'); + }); +}); + // The README state table is COMPLETE on a green tree (#7257 back-filled the two // rows that were missing), so `pnpm check:liveness` passing says nothing about // whether this direction can fire. Same argument as the evidence guard above, diff --git a/packages/spec/scripts/liveness/evidence.mts b/packages/spec/scripts/liveness/evidence.mts index 7026b6a6f4..06c16f0c84 100644 --- a/packages/spec/scripts/liveness/evidence.mts +++ b/packages/spec/scripts/liveness/evidence.mts @@ -59,6 +59,32 @@ export interface EvidenceCitation { line: number; } +/** + * A local path citation anchored to a SYMBOL: `packages/…/file.ts#dispatchFlowAction`. + * + * WHY A SYMBOL AND NOT (ONLY) A LINE (#12516). A line citation rots IN RANGE: + * the file exists, the line is inside it, the file names the key — and the + * consumer has moved to a different line of the same file, so every check + * passes and the pointer is still wrong. Measured on two entries `action.json` + * had repointed with fresh line numbers on 2026-08-25: both had drifted by + * 2026-08-26, because the cited file is 1600+ lines and actively edited — the + * more precisely a line is cited, the faster it rots. A symbol MOVES WITH the + * consumer, so the pointer survives exactly the movement that rots a line, and + * when the consumer is renamed or deleted the symbol is genuinely gone and the + * gate goes red — which is the direction a stale line can never produce. + * + * The `#` separator is the proof-ref convention (`#`, + * ADR-0054) applied to the citation grammar. A line may ride along + * (`file.ts#symbol:150`, either order) — it stays a human convenience and is + * still bounded by `checkCitationLines`; the symbol is the load-bearing half. + */ +export interface EvidenceAnchor { + /** The path, exactly as it appears in `local`. */ + path: string; + /** The anchored symbol text, exactly as written after `#` (validated later). */ + symbol: string; +} + export interface EvidenceScan { /** Repo-rooted paths attributed to THIS repo — these must resolve. */ local: string[]; @@ -71,32 +97,62 @@ export interface EvidenceScan { * and collapsing them would drop every citation but the first. */ localCitations: EvidenceCitation[]; + /** + * The subset of `local` whose citation anchors a symbol (`path#symbol`) — + * the only citations a symbol check can falsify. Deduped on `path#symbol` + * for the same reason `localCitations` dedupes on `path:line`. Foreign + * anchors are never collected, for the same reason foreign lines are not: + * the file is legitimately absent here, so every symbol in it would read as + * gone. + */ + localAnchors: EvidenceAnchor[]; } interface TokenParts { - /** The token with surrounding punctuation and any line suffix removed. */ + /** The token with surrounding punctuation and any line/anchor suffix removed. */ path: string; /** The line suffix's value, or `null` when the token carries none. */ line: number | null; + /** The `#symbol` anchor's text, or `null` when the token carries none. */ + anchor: string | null; } /** - * Strip surrounding punctuation and split off any `:123` / `:12-34` line suffix. - * The trailing class includes `:` so a realm marker written `objectui:` reduces - * to `objectui`; a line suffix (`file.ts:150`) ends in a digit, so it survives - * that pass and is split off by the line-number rule after it. + * Strip surrounding punctuation and split off any `:123` / `:12-34` line suffix + * and any `#symbol` anchor suffix. The trailing class includes `:` so a realm + * marker written `objectui:` reduces to `objectui`; a line suffix + * (`file.ts:150`) ends in a digit, so it survives that pass and is split off by + * the line-number rule after it. * * The line is RETURNED rather than discarded (it used to be dropped on the * floor here) — a citation's line is the half of it a moved consumer rots * first, and a parser that cannot see the line cannot let any gate bound it. + * + * The anchor is split off in EITHER order relative to the line + * (`file.ts#symbol:150` and `file.ts:150#symbol` both parse) on purpose: an + * order the parser did not accept would not fail — the token would just stop + * matching PATH_RE and quietly become prose, taking the existence check down + * with it. The anchor's TEXT is deliberately permissive here (anything but + * whitespace, `#`, `/`); whether it is a well-formed symbol is judged by + * `checkEvidenceAnchors`, so a typo'd anchor is a loud finding rather than a + * silently-ignored token — the same asymmetry `verifiedAt` applies to a + * malformed date. */ function bareToken(raw: string): TokenParts { - const trimmed = raw + let t = raw .replace(/^[([{<"'`,;]+/, '') .replace(/[)\]}>"'`,;.:]+$/, ''); - const m = /:(\d+)(?:-(\d+))?$/.exec(trimmed); - if (!m) return { path: trimmed, line: null }; - return { path: trimmed.slice(0, m.index), line: Number(m[2] ?? m[1]) }; + let line: number | null = null; + let anchor: string | null = null; + // Two suffixes at most, one strip per pass, order-independent. + for (let i = 0; i < 2; i++) { + const lm = /:(\d+)(?:-(\d+))?$/.exec(t); + if (line === null && lm) { line = Number(lm[2] ?? lm[1]); t = t.slice(0, lm.index); continue; } + const am = /#([^\s#/]*)$/.exec(t); + if (anchor === null && am) { anchor = am[1]; t = t.slice(0, am.index); continue; } + break; + } + return { path: t, line, anchor }; } /** @@ -113,10 +169,11 @@ export function scanEvidence(evidence: string): EvidenceScan { const local: string[] = []; const foreign: string[] = []; const citations: EvidenceCitation[] = []; + const anchors: EvidenceAnchor[] = []; let realm = LOCAL_REALM; for (const raw of String(evidence).split(/\s+/)) { - const { path: token, line } = bareToken(raw); + const { path: token, line, anchor } = bareToken(raw); const asRealm = token.toLowerCase(); if (FOREIGN_REALMS.includes(asRealm)) { realm = asRealm; continue; } @@ -133,6 +190,7 @@ export function scanEvidence(evidence: string): EvidenceScan { // several consumers, and seeing only its head would leave the rest of the // chain exactly as unfalsifiable as before. if (line !== null) citations.push({ path: token, line }); + if (anchor !== null) anchors.push({ path: token, symbol: anchor }); } } @@ -140,7 +198,12 @@ export function scanEvidence(evidence: string): EvidenceScan { if (/[;)]/.test(raw)) realm = LOCAL_REALM; } - return { local: dedupe(local), foreign: dedupe(foreign), localCitations: dedupeCitations(citations) }; + return { + local: dedupe(local), + foreign: dedupe(foreign), + localCitations: dedupeCitations(citations), + localAnchors: dedupeAnchors(anchors), + }; } function dedupe(xs: string[]): string[] { @@ -157,6 +220,16 @@ function dedupeCitations(cs: EvidenceCitation[]): EvidenceCitation[] { }); } +function dedupeAnchors(as: EvidenceAnchor[]): EvidenceAnchor[] { + const seen = new Set(); + return as.filter((a) => { + const k = `${a.path}#${a.symbol}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); +} + export interface EvidenceCheck extends EvidenceScan { /** Local paths that do not exist — genuinely rotted pointers. */ missing: string[]; @@ -164,7 +237,7 @@ export interface EvidenceCheck extends EvidenceScan { /** Scan an evidence string and resolve its local paths against the filesystem. */ export function checkEvidence(evidence: unknown, exists: (path: string) => boolean): EvidenceCheck { - if (typeof evidence !== 'string') return { local: [], foreign: [], localCitations: [], missing: [] }; + if (typeof evidence !== 'string') return { local: [], foreign: [], localCitations: [], localAnchors: [], missing: [] }; const scan = scanEvidence(evidence); return { ...scan, missing: scan.local.filter((p) => !exists(p)) }; } @@ -216,3 +289,71 @@ export function checkCitationLines( } return out; } + +/** + * The symbol grammar an anchor must satisfy: one JS/TS identifier. Anything + * else — a hyphenated proof-id shape, a dotted member path, an empty suffix — + * is MALFORMED and fails loudly rather than parsing to nothing, because an + * anchor that quietly degrades to prose takes the whole citation's existence + * check down with it (the token stops matching PATH_RE), which is the silent + * no-op shape this ledger exists to catch. + */ +const SYMBOL_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** + * Does `content` name this symbol as a WORD? Identifier-bounded rather than + * `\b`-bounded: `$` is a legal identifier character that `\b` treats as a + * boundary, so `\bfoo\b` would let `foo` satisfy an anchor at `foo$bar`. + */ +export function isSymbolNamed(content: string, symbol: string): boolean { + const esc = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(? string | null, +): AnchorCheck { + const checked: EvidenceAnchor[] = []; + const unresolved: EvidenceAnchor[] = []; + const malformed: EvidenceAnchor[] = []; + for (const a of scan.localAnchors) { + if (!SYMBOL_RE.test(a.symbol)) { malformed.push(a); continue; } + const content = readFile(a.path); + if (content === null) continue; + checked.push(a); + if (!isSymbolNamed(content, a.symbol)) unresolved.push(a); + } + return { checked, unresolved, malformed }; +} diff --git a/packages/spec/scripts/liveness/evidence.test.ts b/packages/spec/scripts/liveness/evidence.test.ts index 5b8898fcc2..ef9d37f7c7 100644 --- a/packages/spec/scripts/liveness/evidence.test.ts +++ b/packages/spec/scripts/liveness/evidence.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; -import { checkCitationLines, checkEvidence, countLines, scanEvidence } from './evidence.mts'; +import { checkCitationLines, checkEvidence, checkEvidenceAnchors, countLines, isSymbolNamed, scanEvidence } from './evidence.mts'; const here = dirname(fileURLToPath(import.meta.url)); const specRoot = resolve(here, '../..'); @@ -96,7 +96,7 @@ describe('checkEvidence', () => { }); it('tolerates a non-string evidence value', () => { - expect(checkEvidence(undefined, none)).toEqual({ local: [], foreign: [], localCitations: [], missing: [] }); + expect(checkEvidence(undefined, none)).toEqual({ local: [], foreign: [], localCitations: [], localAnchors: [], missing: [] }); expect(checkEvidence(42, none).missing).toEqual([]); }); }); @@ -167,6 +167,104 @@ describe('scanEvidence — line citations', () => { }); }); +// #12516: a line citation rots IN RANGE — the consumer moves within its file and +// every check stays green. A `path#symbol` anchor moves WITH the consumer; these +// pin its extraction, and the resolution check is below. +describe('scanEvidence — symbol anchors (#12516)', () => { + it('retains the anchor of a `path#symbol` pointer, and the path stays local', () => { + const r = scanEvidence('packages/runtime/src/action-execution.ts#dispatchFlowAction (flow dispatch)'); + expect(r.local).toEqual(['packages/runtime/src/action-execution.ts']); + expect(r.localAnchors).toEqual([{ path: 'packages/runtime/src/action-execution.ts', symbol: 'dispatchFlowAction' }]); + }); + + it('parses an anchor and a line together, in EITHER order', () => { + // An order the parser refused would not fail — the token would quietly stop + // matching PATH_RE and become prose, taking the existence check with it. + for (const token of ['packages/a/src/x.ts#dispatch:120', 'packages/a/src/x.ts:120#dispatch']) { + const r = scanEvidence(token); + expect(r.local, token).toEqual(['packages/a/src/x.ts']); + expect(r.localCitations, token).toEqual([{ path: 'packages/a/src/x.ts', line: 120 }]); + expect(r.localAnchors, token).toEqual([{ path: 'packages/a/src/x.ts', symbol: 'dispatch' }]); + } + }); + + it('records several anchors of ONE file separately, deduped on path#symbol', () => { + const r = scanEvidence( + 'packages/a/src/x.ts#alpha (reads it) + packages/a/src/x.ts#beta (writes it) + packages/a/src/x.ts#alpha (again)', + ); + expect(r.localAnchors.map((a) => a.symbol)).toEqual(['alpha', 'beta']); + }); + + it('never collects a FOREIGN anchor — the file is legitimately absent here', () => { + const r = scanEvidence('objectui: packages/app-shell/src/x.tsx#RecordDetailView'); + expect(r.foreign).toEqual(['packages/app-shell/src/x.tsx']); + expect(r.localAnchors).toEqual([]); + }); + + it('does not read an issue reference as an anchor', () => { + // `(#4352` is a house-style token in evidence prose; its "path" half is + // empty, so it is prose, exactly as before the anchor grammar existed. + const r = scanEvidence('packages/a/src/x.ts:385 (the #4352 gate decides)'); + expect(r.local).toEqual(['packages/a/src/x.ts']); + expect(r.localAnchors).toEqual([]); + }); + + it('retains a malformed anchor for the checker to fail, rather than dropping it', () => { + // A dropped anchor is a silently-unheld standard — the checker must SEE it + // to fail it (the `verifiedAt` malformed-date asymmetry). + const r = scanEvidence('packages/a/src/x.ts#not-an-identifier'); + expect(r.localAnchors).toEqual([{ path: 'packages/a/src/x.ts', symbol: 'not-an-identifier' }]); + }); +}); + +describe('isSymbolNamed', () => { + it('matches an identifier as a whole word', () => { + expect(isSymbolNamed('await dispatchFlowAction(deps)', 'dispatchFlowAction')).toBe(true); + }); + + it('rejects a prefix of a longer identifier — identifier-bounded, not substring', () => { + expect(isSymbolNamed('await dispatchFlowAction(deps)', 'dispatch')).toBe(false); + }); + + it('treats `$` as an identifier character, which `\\b` does not', () => { + expect(isSymbolNamed('const foo$bar = 1', 'foo')).toBe(false); + expect(isSymbolNamed('const foo$bar = 1', 'foo$bar')).toBe(true); + }); +}); + +describe('checkEvidenceAnchors', () => { + const content = (s: string) => () => s; + + it('resolves an anchor whose symbol the cited file names', () => { + const scan = scanEvidence('packages/a/src/x.ts#alpha'); + const r = checkEvidenceAnchors(scan, content('export function alpha() {}')); + expect(r.checked).toHaveLength(1); + expect(r.unresolved).toEqual([]); + expect(r.malformed).toEqual([]); + }); + + it('flags an anchor whose symbol left the file — the rot a line bound cannot see', () => { + // The #12516 shape replayed: the consumer moved/renamed, the file still + // exists, every line is still in range, the file may still name the key — + // and the symbol is gone. + const scan = scanEvidence('packages/a/src/x.ts#dispatchFlowAction'); + const r = checkEvidenceAnchors(scan, content('export function dispatchFlowActionMoved() {}')); + expect(r.unresolved).toEqual([{ path: 'packages/a/src/x.ts', symbol: 'dispatchFlowAction' }]); + }); + + it('reports a malformed anchor instead of judging it', () => { + const scan = scanEvidence('packages/a/src/x.ts#not-an-identifier packages/a/src/y.ts#'); + const r = checkEvidenceAnchors(scan, content('anything')); + expect(r.malformed.map((a) => a.symbol)).toEqual(['not-an-identifier', '']); + expect(r.checked).toEqual([]); + }); + + it('says nothing about a file it cannot read — that verdict belongs to the existence check', () => { + const scan = scanEvidence('packages/a/src/gone.ts#alpha'); + expect(checkEvidenceAnchors(scan, () => null)).toEqual({ checked: [], unresolved: [], malformed: [] }); + }); +}); + describe('countLines — what a citation can address', () => { it('counts a trailing newline as terminating the last line, not opening a new one', () => { // `wc -l` semantics. The off-by-one that decides whether a 717-line file @@ -274,4 +372,35 @@ describe('shipped ledgers', () => { // retaining lines would satisfy the assertion above by extracting nothing. expect(citations).toBeGreaterThan(100); }); + + it('every local `path#symbol` anchor names a symbol its file contains (#12516)', () => { + const bad: string[] = []; + let anchors = 0; + const readFile = (p: string): string | null => { + const f = join(repoRoot, p); + return existsSync(f) ? readFileSync(f, 'utf8') : null; + }; + for (const f of readdirSync(ledgerRoot).filter((x) => x.endsWith('.json'))) { + const ledger = JSON.parse(readFileSync(join(ledgerRoot, f), 'utf8')); + const visit = (key: string, entry: any) => { + for (const field of ['evidence', 'producer']) { + if (typeof entry?.[field] !== 'string') continue; + const scan = checkEvidence(entry[field], () => true); + const r = checkEvidenceAnchors(scan, readFile); + anchors += r.checked.length; + r.unresolved.forEach((a) => bad.push(`${ledger.type}/${key} → ${a.path}#${a.symbol} (symbol gone)`)); + r.malformed.forEach((a) => bad.push(`${ledger.type}/${key} → ${a.path}#${a.symbol} (malformed)`)); + } + }; + for (const [key, entry] of Object.entries(ledger.props || {})) { + visit(key, entry); + for (const [ck, centry] of Object.entries(entry?.children || {})) visit(`${key}.${ck}`, centry); + } + } + expect(bad).toEqual([]); + // Non-vacuity: the two #12516 repoints are the day-one anchored population; + // a parser that stopped extracting anchors would pass the line above by + // asking nothing. + expect(anchors).toBeGreaterThanOrEqual(2); + }); });