Skip to content

Commit 0a5851d

Browse files
os-billclaude
andauthored
fix(scripts): refuse pathlessLineCitations on a corpus that declares a doc projection (#18884)
Fixes #18845 Clause-②: no `defineCorpus` took `pathlessLineCitations: true` from any corpus, so the prose-vs-data distinction that declaration rests on was advisory: the next corpus author either remembered it or did not. This makes it mechanical. A corpus that declares a `docProjection` may not declare `pathlessLineCitations: true`; the refusal fires at registration and names the corpus id and both declarations, so the author is told which of the two to drop. ## The reading this rests on, re-taken first The card's cost basis — 222 + 23 hits on the prose corpora, "essentially all false positives" — was #18592's dev's reading, transcribed onto the card and into two script headers, and never run twice. Re-taken here on `b0b5f31cc6`, through the extractor as it ships (fences skipped, de-duplicated against the path-anchored passes), over each corpus's own declared population and its own projection: | corpus | projection | docs | admitted | colon form | `L` pin | |---|---|---|---|---|---| | `docs/adr/**` | none | 139 | 17 | 5 | 12 | | `scripts/**` | `commentProse` | 264 | 50 | 38 | 12 | | `packages/spec/src/**` | `commentProse` | 1458 | 70 | 69 | 1 | | the system-context page | none | 1 | 0 | 0 | 0 | **137 (112 + 25), not 245.** The gap is METHOD, not tree drift: re-run against the very commit the original names, PR head `88172978c7`, the extractor still admits 112 + 25. Counting RAW REGEX MATCHES over the same projected text — no fence skip, no de-duplication — gives 230 + 25 on that same tree, which is the figure that travelled. So the transcribed number overstates what the option actually admits by about a half, and it has now been corrected in this module's header with the commit it was taken against. **The sharper half, and the one that makes this a rule rather than advice: of the 120 hits in the two projected corpora, ZERO inherit a path this tree tracks.** Both of those corpora declare `judgeUntrackedLineAnchors: false`, so every one of the 120 is DECLINED, not judged. Opting in there buys unjudged residual — the `scripts/**` gate's own waived count goes 15 to 65, measured — and not one finding an author could act on, at exit 0, with nothing red to notice it by. A projected corpus that opts in does not break; it quietly stops meaning what its numbers say. Sampled by hand, the character of the 120 is: this module's own header teaching the spellings it bans (23 hits across three gate headers, including 15 in `scripts/symbol-anchors.mjs` itself), a scenario label (`L11`, `L12`), a docker tag tail, a count after a colon in a quoted ruling, and — in `packages/spec/src/ui/component.zod.ts` and its test — dated read positions inside a SIBLING repository's renderer at a pinned sha, which are real line pointers but name nothing this tree holds. So "essentially all false positives" reproduces for `scripts/**`; for `packages/spec/src/**` the honest restatement is "real but unresolvable, every one of them". The cost is the same either way, and it is silent. ## The boundary, and what the other arm would have cost The card left one call to the dev: refuse on ANY `docProjection`, or only on `docProjection === commentProse`. Both readings name the same two corpora today (every projection in the tree is `commentProse`), so no measurement separates them — it is a choice about whom the rule governs later. **Chosen: any projection.** A projection is the corpus saying the judged text is authored prose carved out of something else; that is exactly the premise the option contradicts. And the two failure directions are not symmetric. An identity test is evaded by a wrapper around the same function — `(src) => commentProse(src)` — or by the next projection of the same kind, silently, with nobody deciding it; a self-test case pins that wrapper as refused here. This arm's failure is the opposite shape: a hypothetical projected DATA corpus is over-refused LOUDLY, at registration, by a message that names the exit the module's own ruling already prescribes — widen the core, with the maintainer, rather than fork the rule. **What the narrow arm would have bought, and what it costs:** it would let a future data-shaped projection (say, one that carves JSON string values out of a data file) opt in without a conversation. The price is that the rule's reach then changes silently whenever a projection is renamed, wrapped or copied, and a governance rule keyed to one function's identity is a rule whose population nobody is tracking. ## Evidence **LIT — it must fire, both legs on the real corpus file.** `pathlessLineCitations: true` was injected into the `scripts/**` corpus registration (byte-identical mutation in both legs, blob `06d4eb83dc`): - before the change: `node scripts/check-scripts-symbol-anchors.mjs` exits 0, accepts silently, and reports its waived count rising 15 to 65 — the silent cost above, observed; - after the change: exits 1, and the first line of the refusal is ``` defineCorpus: corpus `scripts` declares a `docProjection` AND `pathlessLineCitations: true`. ``` Both legs restored with `git checkout HEAD -- PATH` and proven restored by bytes: on-disk hash back to the HEAD blob `68940d132c`, `git diff HEAD` empty, `git status --porcelain` empty. **DARK — it must not fire on the one live opt-in.** `scripts/check-platform-checklist.mjs` registers the tree's only `pathlessLineCitations: true`, and it declares no projection. Its whole-tree output is byte-identical before and after — same sha256 `f1abe15388f9`, same exit 0 — which is what says the new rule bites projected corpora only. (That gate was measured deterministic first: two consecutive pre-change runs are byte-identical.) **The other four corpora are unmoved.** `check-scripts-symbol-anchors`, `check-spec-docblock-symbol-anchors`, `check-adr-symbol-anchors` and `check-system-context-census` all exit 0, and the two whose summary lines were captured before the change print byte-identical summaries after it. **Self-test.** Seven cases added in a new block 11f — the refusal, the two things its message must name, the wrapped-projection row that pins the rule to the declaration rather than to one function's identity, and three controls: the live DATA opt-in shape, a projected corpus that never mentions the option, and the option declared OFF beside a projection. The battery floor is raised 149 to 156 with its ledger line. The count was read from the instrument, not assumed: with the floor temporarily pinned at 9999 the battery reports `registered 156 case(s)`. The cases ride this file's ONE battery deliberately — its roster is per section banner and this file carries none, which is why a single hoisted battery is its landed shape. **Gate families.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 30 commands for this diff; 29 ran green, with the 30th (`pnpm check:pm-dispatch-gates`, a several-minute self-test) recorded in the report. The derivation was taken at HEAD `ed9b43360d` while `origin/main` stood at `0b31d90fb3`; the tool flags one derivation input as stale across that range and the derived command list is identical either way. ## Acceptance notes - `scripts/check-platform-checklist.mjs` carries the same transcribed figure in its own corpus ledger comment ("these two turned on corpus-wide fire 245 times"). It is read-only for this card, so it still carries a number this PR proves wrong by about a half. Noted, not filed; the successor is whichever PR next touches that ledger's line-citation binding block. - `docs/adr/**` declares no projection, so this rule leaves it free to opt in — and there the option is not harmless: 2 of its 17 hits are dev-server ports, and one inherits a tracked path and would become a red finding. The boundary is deliberately about who has DECLARED their documents to be prose, not about where the option happens to be a bad idea. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1fb36ca commit 0a5851d

1 file changed

Lines changed: 103 additions & 14 deletions

File tree

‎scripts/symbol-anchors.mjs‎

Lines changed: 103 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,11 @@
115115
* span (`id :140 and version :202`) and an `L` pin (`~L7246-7331`), both
116116
* continuing a filename named earlier in the same sentence. This is the
117117
* LAST half of the checklist's fork (#18592); the symbol half left at
118-
* #18107. ⛔ Default OFF is a measurement: in PROSE a colon before digits
119-
* is punctuation, and turning these on corpus-wide would fire 245 times
120-
* across the prose corpora, essentially all false positives.
118+
* #18107. ⛔ Default OFF is a measurement, and ⛔ a corpus declaring a
119+
* `docProjection` may not declare it AT ALL: `defineCorpus` refuses that
120+
* pair by name (#18845), because a projection has already said the judged
121+
* text is prose and in PROSE a colon before digits is punctuation. What
122+
* the option admits there is re-measured below.
121123
*
122124
* ⭐ A dotted `#Outer.member` is also part of the grammar, and `sweepCorpus`
123125
* requires EVERY segment to resolve. Admitting it was additive: a dotted symbol
@@ -399,16 +401,33 @@ const TILDE_LINE = /~\s*`(\d{2,5})`/g;
399401
* carries no path at all, only a number, so nothing can even report WHICH file
400402
* rotted out from under it.
401403
*
402-
* ⛔ DEFAULT OFF, and that is a MEASUREMENT, not caution. Turned on for every
403-
* corpus, these two would fire 222 and 23 times respectively across
404-
* `docs/adr/**`, `scripts/**` and `packages/spec/src/**` -- a dev-server port
405-
* written as a parenthesised colon-port, a two-character scenario label, a
406-
* docblock pointing back at a line of its own -- essentially all of them false
407-
* positives, because in PROSE a
408-
* colon before digits is punctuation. In this checklist's DATA, where every
409-
* citation lives inside a JSON string value beside the filename it continues,
410-
* it is a line pin. Which of the two a corpus is, is the corpus's declaration
411-
* to make -- exactly as `unspannedAnchors` is.
404+
* ⛔ DEFAULT OFF, and that is a MEASUREMENT, not caution -- RE-TAKEN on
405+
* `b0b5f31cc6` in this repository, because the first reading of it travelled
406+
* onto three cards as the whole cost argument and had never been run twice.
407+
* Turned on for the three registered corpora that read prose, these two admit
408+
* 112 and 25 citations across `docs/adr/**`, `scripts/**` and
409+
* `packages/spec/src/**`: a dev-server port written as a parenthesised
410+
* colon-port, a two-character scenario label, a docblock pointing back at a
411+
* dated read position in a sibling repository's file, and this module's own
412+
* header teaching the spellings it bans. ⚠️ The figures that travelled as 222
413+
* and 23 count RAW REGEX MATCHES over the same text (230 and 25 when re-run
414+
* that way on the same tree): they subtract neither the fenced blocks the
415+
* extractor skips nor the citations the path-anchored passes already recorded,
416+
* so they overstate what the option actually admits by about a half.
417+
*
418+
* ⭐ The sharper half of the same re-take, and the half that makes this a RULE
419+
* rather than advice: of the 120 admitted in the two PROJECTED corpora, ZERO
420+
* inherit a path this tree tracks. Both of those corpora decline what they
421+
* cannot resolve, so opting in there buys unjudged residual -- the `scripts/**`
422+
* gate's own waived count goes 15 to 65 -- and NOT ONE finding an author could
423+
* act on, at exit 0, with nothing red to notice it by. That is the combination
424+
* `defineCorpus` now refuses outright (#18845).
425+
*
426+
* In this checklist's DATA, where every citation lives inside a JSON string
427+
* value beside the filename it continues, it is a line pin. Which of the two a
428+
* corpus is, is the corpus's declaration to make -- exactly as
429+
* `unspannedAnchors` is -- but a corpus declaring a `docProjection` has already
430+
* made it.
412431
*
413432
* ⚠️ The numeric shape here is deliberately TIGHTER than `LINE_SPEC`: a plain
414433
* run of digits with one optional hyphen range, no comma list, no slash list,
@@ -812,6 +831,33 @@ export function defineCorpus(spec) {
812831
if (!Array.isArray(excludeDirs) || excludeDirs.some((d) => typeof d !== 'string' || d === '' || d.includes('/'))) {
813832
throw new Error('defineCorpus: `excludeDirs` must be an array of plain directory NAMES (no separators)');
814833
}
834+
/* ⛔ A corpus that declares a PROJECTION may not declare `pathlessLineCitations`,
835+
* and the pair is refused at REGISTRATION because the two declarations say
836+
* opposite things about the same document. A projection is the corpus saying
837+
* the judged text is authored PROSE carved out of something else; the two
838+
* path-less spellings are a DATA corpus's way of writing a second pointer
839+
* beside a filename it has already named. The cost of getting that backwards
840+
* is measured in the header above, and it is paid SILENTLY -- a projected
841+
* corpus that opts in declines every one of the citations it admits, so the
842+
* gate stays green while its unjudged residual grows.
843+
*
844+
* ⭐ Keyed on ANY projection, ⛔ not on one projection function's identity.
845+
* Both readings name the same two corpora today, so this is a choice about
846+
* whom the rule governs LATER, and the two failure directions are not
847+
* symmetric: an identity test is evaded by a wrapper of the same function, or
848+
* by the next projection of the same kind, silently and with nobody deciding
849+
* it; this one over-refuses a hypothetical PROJECTED DATA corpus loudly, at
850+
* registration, and the ruling this module implements already names that exit
851+
* -- widen the core rather than fork the rule. */
852+
if (docProjection !== null && pathlessLineCitations) {
853+
throw new Error(
854+
`defineCorpus: corpus \`${id}\` declares a \`docProjection\` AND \`pathlessLineCitations: true\`. `
855+
+ 'A projection declares the judged text is authored PROSE, where a colon before digits is punctuation; '
856+
+ 'the two path-less spellings are for a DATA corpus writing a second pointer beside a filename it has '
857+
+ 'already named. Drop one of the two: sweep the raw document if this corpus is data, or leave '
858+
+ '`pathlessLineCitations` off and keep citing a path.',
859+
);
860+
}
815861
return {
816862
id, label, docRoots, docPattern, crossRepos, checkBarePaths, docProjection,
817863
judgeUntrackedLineAnchors, excludeDirs, unspannedAnchors, pathlessLineCitations,
@@ -1131,8 +1177,17 @@ function assert(cond, msg) { if (!cond) { console.error(`❌ symbol-anchors --se
11311177
// line-citation grammar moved here — every spelling it caught and
11321178
// every colon-then-digit neighbour it refused — and the de-duplication
11331179
// pair, the inheritance pair and the default-off control joined them.
1180+
// 149 → 156 when `defineCorpus` began REFUSING a `docProjection` beside
1181+
// `pathlessLineCitations` (#18845): the refusal, the two things its
1182+
// message must name, the wrapped-projection row that pins the rule to
1183+
// the declaration rather than to one function's identity, and three
1184+
// controls — the live DATA opt-in, a projected corpus that never
1185+
// mentions the option, and the option declared OFF beside a
1186+
// projection. ⛔ The cases ride the file's ONE battery on purpose:
1187+
// the roster is per SECTION BANNER and this file carries none, which
1188+
// is why a single hoisted battery is its landed shape.
11341189
const SELF_TEST_BATTERIES = Object.freeze({
1135-
'symbol-anchors self-test': 149,
1190+
'symbol-anchors self-test': 156,
11361191
});
11371192

11381193
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -1551,6 +1606,40 @@ export function selfTest() {
15511606
check(extractAnchors('a template at packages/a/page.html:42 here').lineAnchors.length === 1,
15521607
'`html` is in the shared anchorable vocabulary — a citation into a template page is read like any other');
15531608

1609+
// 11f. ⭐ WHO MAY DECLARE `pathlessLineCitations` (#18845). The option is a
1610+
// DATA corpus's declaration, and a corpus declaring a `docProjection`
1611+
// has already said its documents are prose — so the pair is refused at
1612+
// REGISTRATION instead of being left to whoever writes the next corpus.
1613+
// ⛔ Driven in BOTH directions on the SAME two spellings, because a
1614+
// refusal that also bit the one LIVE opt-in would delete a working
1615+
// corpus: the control rows below are that corpus's exact shape (no
1616+
// projection, the option on) and the two prose corpora's exact shape (a
1617+
// projection, the option never mentioned).
1618+
const proseSpec = { id: 'prose-corpus', label: 'prose', docRoots: ['a'], docProjection: commentProse };
1619+
let pairError = null;
1620+
try { defineCorpus({ ...proseSpec, pathlessLineCitations: true }); } catch (err) { pairError = err; }
1621+
check(pairError !== null,
1622+
'a corpus declaring BOTH a `docProjection` and `pathlessLineCitations: true` must be REFUSED at registration');
1623+
check(String(pairError && pairError.message).includes('prose-corpus'),
1624+
`the refusal must NAME the corpus — got ${JSON.stringify(String(pairError && pairError.message).slice(0, 140))}`);
1625+
check(String(pairError && pairError.message).includes('docProjection')
1626+
&& String(pairError && pairError.message).includes('pathlessLineCitations'),
1627+
'the refusal must name BOTH declarations — an author told only that something is wrong cannot know which of the two to drop');
1628+
check(defineCorpus({ id: 'data-corpus', label: 'data', docRoots: ['a'], unspannedAnchors: true, pathlessLineCitations: true }).pathlessLineCitations === true,
1629+
'CONTROL: a DATA corpus — no projection — may still declare `pathlessLineCitations`; this rule bites PROSE only');
1630+
check(defineCorpus(proseSpec).docProjection === commentProse,
1631+
'CONTROL: a projected corpus that never mentions the option registers exactly as it did before');
1632+
check(defineCorpus({ ...proseSpec, pathlessLineCitations: false }).pathlessLineCitations === false,
1633+
'CONTROL: the refusal is about the PAIR — declaring the option OFF beside a projection is not a refusal');
1634+
// ⭐ Keyed on ANY projection, ⛔ never on one function's identity: an identity
1635+
// test is evaded by a wrapper around the very same projection, silently.
1636+
let wrappedError = null;
1637+
try {
1638+
defineCorpus({ id: 'wrapped-corpus', label: 'wrapped', docRoots: ['a'], docProjection: (src) => commentProse(src), pathlessLineCitations: true });
1639+
} catch (err) { wrappedError = err; }
1640+
check(wrappedError !== null && String(wrappedError.message).includes('wrapped-corpus'),
1641+
'a projection WRAPPED in another function is refused too — the rule reads the DECLARATION, not one projection function\'s identity');
1642+
15541643
// 12. ⛔ THE ENVIRONMENT ISOLATION PIN (#16624), and it is the one case here
15551644
// that spawns `git`. `sweepCorpus` resolves through `git ls-files`, and
15561645
// for its whole life it passed NO ENVIRONMENT OF ITS OWN. From a plain

0 commit comments

Comments
 (0)