From 66c0ab9da00e514457ec433adb8a8dc9f6486243 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:42:25 +0000 Subject: [PATCH 1/3] wip: anchor-normalising comparator in doc-line-anchors --- scripts/doc-line-anchors.mjs | 71 +++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/scripts/doc-line-anchors.mjs b/scripts/doc-line-anchors.mjs index 5cf123cc85..3f6e6aeb51 100644 --- a/scripts/doc-line-anchors.mjs +++ b/scripts/doc-line-anchors.mjs @@ -177,14 +177,21 @@ function countLines(text, offset) { /** * Every `file:line` anchor a docs page writes, in document order. * + * `start`/`end` are byte offsets of the anchor's code span **in `rawText`**, not in + * the blanked copy this walks: `blankFencedBlocks` and `blankFrontmatter` both + * preserve length, so the two agree offset for offset. They exist so a caller can + * REWRITE an anchor in place -- `blankAnchorLineNumbers` below is the one in-tree + * consumer -- without re-deriving spans through a second, divergent parser. + * * @param {string} rawText The page source, frontmatter and fences included. * @returns {{ spelling: string, line: number, kind: 'full'|'continuation'|'range-end', - * raw: string, docLine: number }[]} + * raw: string, docLine: number, start: number, end: number }[]} */ export function extractLineAnchors(rawText) { const text = blankFencedBlocks(blankFrontmatter(rawText)); const spans = extractCodeSpans(text); - /** @type {{ spelling: string, line: number, kind: string, raw: string, docLine: number }[]} */ + /** @type {{ spelling: string, line: number, kind: string, raw: string, docLine: number, + * start: number, end: number }[]} */ const anchors = []; let currentFile = null; let previousSpan = null; @@ -198,6 +205,8 @@ export function extractLineAnchors(rawText) { kind: 'full', raw: span.value, docLine: span.line, + start: span.start, + end: span.end, }); previousSpan = span; continue; @@ -210,6 +219,8 @@ export function extractLineAnchors(rawText) { kind: 'continuation', raw: span.value, docLine: span.line, + start: span.start, + end: span.end, }); previousSpan = span; continue; @@ -222,6 +233,8 @@ export function extractLineAnchors(rawText) { kind: 'range-end', raw: span.value, docLine: span.line, + start: span.start, + end: span.end, }); previousSpan = span; continue; @@ -237,6 +250,60 @@ export function extractLineAnchors(rawText) { return anchors; } +/** + * The same page with every anchor's LINE NUMBER replaced by `#`, and nothing else + * touched. + * + * ## What this is for, and why it lives here + * + * It answers one question: *do these two revisions of a page differ in anything + * but anchor line numbers?* Two texts that compare equal after this differ only in + * the half a generator re-derives -- `check-system-context-census.mjs --fix` + * rewrites exactly these numbers and nothing else -- so discarding either revision + * loses nothing a later regeneration cannot restore. Two texts that still differ + * carry a hand-written change, which no generator can restore, and discarding + * either one is a silent deletion. + * + * `scripts/git-merge-regen.mjs` is the consumer: the `merge=os-regen` driver keeps + * OURS whole and drops THEIRS whole, which is correct for a wholly generated file + * and destroys prose on a MIXED one. This function is how the driver tells those + * two cases apart before it defers. + * + * It is written here rather than in the driver because this module is already the + * ONE reader of these anchors (module header), and a second parser -- even a + * three-line regex -- is a second definition of "anchor" that would drift from the + * gate's. Measured on the corpus that motivated the module: a regex over + * `path.ts:NNNN` alone reads 24 of 25 revisions of `system-context.mdx` as + * prose-changing, because CONTINUATION and RANGE_END anchors are not that shape. + * The correct answer, using the walk below, is 1 of 25. + * + * ⚠️ NOT a general "is this page unchanged" test. It deliberately blinds itself to + * line numbers, so a caller that cares whether an anchor MOVED must compare the raw + * texts, or read the anchors themselves. + * + * @param {string} rawText The page source, frontmatter and fences included. + * @returns {string} + */ +export function blankAnchorLineNumbers(rawText) { + const anchors = extractLineAnchors(rawText); + let out = ''; + let cursor = 0; + for (const a of anchors) { + // Anchors arrive in document order and their spans never overlap, so a single + // forward pass is enough; a defensive skip keeps that assumption from silently + // corrupting the output if it ever stops holding. + if (a.start < cursor) continue; + out += rawText.slice(cursor, a.start); + // The LAST run of digits in the span is the line number in all three shapes -- + // `path.ts:1234`, `:1234` and a bare `1234` -- and a path that itself carries + // digits (`0112-codes.ts:55`) keeps them, because the lookahead requires that + // nothing but non-digits follows. + out += rawText.slice(a.start, a.end).replace(/\d+(?=\D*$)/, '#'); + cursor = a.end; + } + return out + rawText.slice(cursor); +} + /** * Every cited path that carries no line number, in document order. * From aac2e75c658ef9c9f8312bb9fa0cbdf82b19e5d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:44:58 +0000 Subject: [PATCH 2/3] feat(devx): route MIXED rows through a losslessness check before deferring --- scripts/git-merge-regen.mjs | 274 ++++++++++++++++++++++++++++++++++++ scripts/regen-artifacts.mjs | 74 ++++++++-- 2 files changed, 340 insertions(+), 8 deletions(-) diff --git a/scripts/git-merge-regen.mjs b/scripts/git-merge-regen.mjs index 5d4c7d7401..a0074c6d6e 100755 --- a/scripts/git-merge-regen.mjs +++ b/scripts/git-merge-regen.mjs @@ -73,8 +73,31 @@ import { ownerOf, ownerRunCommand, } from './regen-artifacts.mjs'; +import { blankAnchorLineNumbers } from './doc-line-anchors.mjs'; import { workspacePackages } from './workspace-enumerator.mjs'; +/** + * The comparators a row's `mixed` field may name (#14064). + * + * A comparator answers ONE question: *given two revisions of this file, is their + * difference confined to the half the generator re-derives?* When it is, dropping + * either revision loses nothing — which is the entire premise of deferring. When it + * is not, the difference includes hand-written text that no `gen:` can restore, and + * a deferral would delete it silently. + * + * Keyed by name rather than by function so the TABLE stays free of imports and + * top-level statements (the shape `check:entry-guard` relies on), and so an + * unrecognised name is a loud refusal here instead of a silent `undefined` there. + */ +const MIXED_COMPARATORS = Object.freeze({ + /** + * Equal modulo `file:line` anchor numbers. The generated half of + * `content/docs/permissions/system-context.mdx` is exactly those numbers: + * `check-system-context-census.mjs --fix` rewrites them and touches nothing else. + */ + 'line-anchors': blankAnchorLineNumbers, +}); + const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); function gitDir(cwd = process.cwd()) { @@ -93,6 +116,92 @@ function markPending(path, cwd = process.cwd()) { return marker; } +/** Read a merge input, or `null` when git supplied nothing for that side. */ +function readSide(file) { + if (!file || !existsSync(file)) return null; + try { + return readFileSync(file, 'utf8'); + } catch { + return null; + } +} + +/** + * For a MIXED row: would deferring — keeping OURS whole and dropping THEIRS whole — + * lose anything a later regeneration cannot restore? (#14064) + * + * Two shapes are lossless, and they are the ones the comparator can PROVE: + * + * 1. THEIRS differs from the ANCESTOR only in the generated half. Then theirs + * contributes nothing but numbers `--fix` will re-derive from the merged tree, + * and dropping it drops nothing. This is the common case by a wide margin — + * 24 of the last 25 main commits to `system-context.mdx`. + * 2. THEIRS and OURS agree once the generated half is blanked. Then whatever + * hand-written change theirs carries, ours carries too, so keeping ours keeps + * it. (Two branches landing the same doc edit, or one rebased onto the other.) + * + * Everything else — including every case where a side is missing or unreadable — is + * reported unsafe. ⛔ The default must be unsafe rather than safe: the failure this + * exists to stop is invisible (exit 0, no markers, gates green over deleted prose), + * so an unreadable input has to become a conflict a human sees, never a deferral + * nobody does. + * + * @returns {{ safe: true, why: string } | { safe: false, why: string }} + */ +function deferralIsLossless(entry, ancestorFile, oursFile, theirsFile) { + const normalize = MIXED_COMPARATORS[entry.mixed]; + if (!normalize) { + return { + safe: false, + why: `\`mixed: '${entry.mixed}'\` names no comparator in git-merge-regen.mjs` + + ` (known: ${Object.keys(MIXED_COMPARATORS).join(', ') || 'none'})`, + }; + } + const ours = readSide(oursFile); + const theirs = readSide(theirsFile); + if (ours === null || theirs === null) { + return { safe: false, why: 'one side of the merge could not be read, so nothing can be proven about it' }; + } + const nTheirs = normalize(theirs); + const ancestor = readSide(ancestorFile); + if (ancestor !== null && nTheirs === normalize(ancestor)) { + return { safe: true, why: 'the incoming side changed nothing but the generated half' }; + } + if (nTheirs === normalize(ours)) { + return { safe: true, why: 'both sides carry the same hand-written text; only the generated half differs' }; + } + return { safe: false, why: 'the incoming side carries hand-written changes that no regeneration can restore' }; +} + +/** + * The MIXED path's answer when deferral would lose prose: give the file a REAL text + * merge instead of dropping a side (#14064). + * + * `git merge-file` writes its result into `%A`, which is also git's output file, so + * a clean merge leaves the union of both sides' prose on disk and the deferral's + * only remaining job — re-deriving the generated half from the merged tree — is + * still done by the mandatory regeneration. That is exactly the resolution a human + * performed by hand on the merge that found this defect, and it is why this limb + * can succeed rather than merely fail loudly. + * + * A conflicting text merge leaves markers and returns non-zero, which is the right + * outcome for "two people edited the same prose": loud, and addressed to someone who + * can actually adjudicate it. + * + * @returns {boolean} true when the text merge was clean + */ +function textMergeInPlace(ancestorFile, oursFile, theirsFile) { + try { + execFileSync('git', ['merge-file', '-L', 'ours', '-L', 'base', '-L', 'theirs', oursFile, ancestorFile, theirsFile], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return true; + } catch { + return false; + } +} + function drive(argv) { // %O %A %B %P — ancestor, ours (also the OUTPUT file), theirs, pathname. const path = argv[3]; @@ -113,6 +222,39 @@ function drive(argv) { return 1; } + // ⭐ A MIXED row (#14064) is only deferrable for the merges where the side being + // dropped carried nothing but the generated half. The unconditional deferral + // below is correct for every wholly-generated row and is a silent deletion here. + if (entry.mixed) { + const verdict = deferralIsLossless(entry, argv[0], argv[1], argv[2]); + if (!verdict.safe) { + const clean = textMergeInPlace(argv[0], argv[1], argv[2]); + if (!clean) { + console.error( + ` ⚠ ${path}\n` + + ` NOT deferred: ${verdict.why}.\n` + + ' This file is MIXED — a generated half plus hand-written prose — so keeping one\n' + + ' side whole would delete the other side\'s prose with no conflict and no red gate.\n' + + ' Text-merged instead, and it CONFLICTS. Resolve the prose by hand; the anchor\n' + + ` numbers do not matter here — take either side and then run:\n` + + ` ${ownerRunCommand(ownerOf(entry), entry.gen)}\n` + + ' which re-derives them from the merged tree.', + ); + return 1; + } + markPending(path); + console.error( + ` ⟳ ${path}\n` + + ` NOT deferred: ${verdict.why}.\n` + + ' This file is MIXED, so it was TEXT-MERGED (cleanly — both sides\' prose is in the\n' + + ' result) rather than resolved to one side. The generated half still needs:\n' + + ` ${ownerRunCommand(ownerOf(entry), entry.gen)}\n` + + ' The pre-commit hook will not let this commit through until you do.', + ); + return 0; + } + } + markPending(path); const dist = entry.readsDist @@ -686,6 +828,136 @@ function endToEnd() { } } +/** + * Every `mixed` row names a comparator that EXISTS and DISCRIMINATES (#14064). + * + * The second half is the one worth writing down. Resolving the name proves only + * that a function is there; a comparator that collapsed everything to the same + * value — `() => ''`, or one whose parser silently stopped finding anchors — would + * report every deferral lossless and restore the exact defect this field was added + * to close, with the driver, this reconciliation and every doc gate still green. + * So each comparator is run against a pair it MUST call equal and a pair it MUST + * call different, which is the firing control the "safe" verdict rests on. + */ +function reconcileMixedComparators() { + const rows = REGEN_ARTIFACTS.filter((e) => e.mixed); + const unknown = rows.filter((e) => !MIXED_COMPARATORS[e.mixed]); + if (unknown.length) { + return fail(`row(s) declare a \`mixed\` comparator that does not exist:\n ` + + unknown.map((e) => `${e.path} → ${e.mixed}`).join('\n ') + + `\n Known comparators: ${Object.keys(MIXED_COMPARATORS).join(', ')}\n` + + ' The driver CONFLICTS on an unknown name rather than deferring, so this is red, not silent —\n' + + ' but a routed mixed file that cannot be adjudicated is a hand-merge every time until it is fixed.'); + } + + // The controls are per comparator, not per row: they pin the comparator's + // discrimination, which is what every row naming it depends on. + const controls = { + 'line-anchors': { + same: ['Prose.\n\n`pkg/src/a.ts:100` and `:205`.\n', 'Prose.\n\n`pkg/src/a.ts:117` and `:990`.\n'], + different: ['Prose.\n\n`pkg/src/a.ts:100`.\n', 'Prose. Extra sentence.\n\n`pkg/src/a.ts:100`.\n'], + }, + }; + for (const name of new Set(rows.map((e) => e.mixed))) { + const control = controls[name]; + if (!control) { + return fail(`comparator '${name}' has no firing control in reconcileMixedComparators.\n` + + ' An unexercised comparator is one that cannot be shown to discriminate, and a comparator\n' + + ' that does not discriminate reports every deferral safe. Add both control pairs.'); + } + const cmp = MIXED_COMPARATORS[name]; + if (cmp(control.same[0]) !== cmp(control.same[1])) { + return fail(`comparator '${name}' calls an anchors-only pair DIFFERENT.\n` + + ' Every merge of a purely re-anchored file would now hand-conflict.'); + } + if (cmp(control.different[0]) === cmp(control.different[1])) { + return fail(`comparator '${name}' calls a prose-changing pair EQUAL.\n` + + ' ⛔ This is the #14064 defect restored: the driver would silently drop the prose again.'); + } + } + console.log(`✓ ${rows.length} mixed row(s) name a comparator that exists and discriminates` + + ` (${[...new Set(rows.map((e) => e.mixed))].join(', ')})`); + return true; +} + +/** + * Prove BOTH limbs of the mixed-row guard against real git (#14064). + * + * `endToEnd` above proves the deferral; this proves the two things the deferral + * must NOT do. Behaviour, not wiring, for the same reason: the failure being + * guarded is a merge that exits 0 with no markers, which is indistinguishable from + * success unless something reads the resulting bytes. + */ +function endToEndMixed() { + const entry = REGEN_ARTIFACTS.find((e) => e.mixed); + if (!entry) { + console.log('✓ end-to-end (mixed): no mixed rows declared — nothing to prove'); + return true; + } + const target = entry.path; + const BASE = '# Page\n\nIntro prose.\n\nThe guard is at `packages/rest/src/rest-server.ts:100`.\n'; + const OURS = '# Page\n\nIntro prose.\n\nThe guard is at `packages/rest/src/rest-server.ts:140`.\n'; + + const run = (theirs, label) => { + const dir = mkdtempSync(join(tmpdir(), 'os-regen-mixed-')); + const git = (...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + try { + git('init', '-q', '--initial-branch=main', '.'); + git('config', 'user.email', 'selftest@objectstack.ai'); + git('config', 'user.name', 'self-test'); + git('config', 'merge.os-regen.name', 'regenerate instead of text-merging'); + git('config', 'merge.os-regen.driver', `node "${join(REPO_ROOT, 'scripts/git-merge-regen.mjs')}" %O %A %B %P`); + mkdirSync(join(dir, dirname(target)), { recursive: true }); + writeFileSync(join(dir, '.gitattributes'), `${target} merge=os-regen\n`); + writeFileSync(join(dir, target), BASE); + git('add', '-A'); + git('commit', '-qm', 'base'); + git('checkout', '-qb', 'incoming'); + writeFileSync(join(dir, target), theirs); + git('commit', '-qam', 'theirs'); + git('checkout', '-q', 'main'); + writeFileSync(join(dir, target), OURS); + git('commit', '-qam', 'ours'); + let conflicted = false; + try { + git('merge', 'incoming', '-m', 'merge'); + } catch { + conflicted = true; + } + return { merged: readFileSync(join(dir, target), 'utf8'), conflicted, status: git('status', '--porcelain') }; + } catch (err) { + return { error: err?.stderr?.toString() || err?.message || String(err) }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + // (1) THEIRS re-anchored only — the 24-in-25 case. Must still defer to OURS. + const anchorsOnly = run('# Page\n\nIntro prose.\n\nThe guard is at `packages/rest/src/rest-server.ts:212`.\n', 'anchors-only'); + if (anchorsOnly.error) return fail(`self-test (mixed, anchors-only): ${anchorsOnly.error}`); + if (anchorsOnly.conflicted) return fail('self-test (mixed): an anchors-only incoming change CONFLICTED — #13646\'s win is gone.'); + if (anchorsOnly.merged !== OURS) { + return fail('self-test (mixed): an anchors-only incoming change was not deferred to OURS.'); + } + + // (2) THEIRS added prose. Whatever happens, that prose must not vanish silently. + const NEEDLE = 'A paragraph only main has.'; + const withProse = run(`# Page\n\nIntro prose.\n\n${NEEDLE}\n\nThe guard is at \`packages/rest/src/rest-server.ts:100\`.\n`, 'prose'); + if (withProse.error) return fail(`self-test (mixed, prose): ${withProse.error}`); + const kept = withProse.merged.includes(NEEDLE); + const loud = withProse.conflicted || /^<{7}|^={7}$|^>{7}/m.test(withProse.merged) || /^(UU|AA)/m.test(withProse.status); + if (!kept && !loud) { + return fail('self-test (mixed): ⛔ incoming PROSE was dropped with no conflict and no marker.\n' + + ' This is #14064 exactly — the merge exits 0, the gates stay green, the documentation is gone.'); + } + if (!kept) { + return fail('self-test (mixed): incoming prose is absent from a merge that could have kept it cleanly.'); + } + + console.log('✓ end-to-end (mixed): anchors-only deferred to OURS; incoming prose survived instead of being dropped'); + return true; +} + if (process.argv.includes('--self-test')) { console.log('git-merge-regen --self-test\n'); const results = [ @@ -697,7 +969,9 @@ if (process.argv.includes('--self-test')) { reconcileOwnership(), hookIsExecutable(), registeredDriverResolves(), + reconcileMixedComparators(), endToEnd(), + endToEndMixed(), ]; console.log( results.every(Boolean) diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index dbc3808300..eec9583ce3 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -40,6 +40,31 @@ export const ROOT_OWNER = '@objectstack/spec-monorepo'; * `DEFAULT_OWNER`. `--self-test` verifies each name against THAT manifest, so a * renamed script fails loudly here instead of silently disarming a path. * + * ## `mixed` — the row is NOT generated whole, and here is when deferring is safe (#14064) + * + * Every other row is generated WHOLE: its generator renders the file's entire + * content in memory and writes it, so nothing on disk survives into the output and + * a merge that keeps one side whole loses nothing a regeneration cannot restore. + * That property is what makes "defer and regenerate" a merge SEMANTICS rather than + * a coin flip, and it is why `NOT_DRIVER_MANAGED` turns MIXED files away + * (`skills/README.md`, `content/docs/ai/skills-reference.mdx`, + * `src/migrations/registry.ts` — each in its own words: "a deferral would launder + * the prose"). + * + * `mixed` is the third answer those two lists could not express. A file can be + * mixed AND still belong here, when the generated half is unreachable by any text + * merge (`content/docs/permissions/system-context.mdx` is the measured case) — but + * then "defer" is only correct for the merges where the discarded side changed + * nothing but the generated half. The value NAMES that equivalence, and + * `git-merge-regen.mjs` holds the mapping from the name to the comparator; an + * unrecognised name makes the driver CONFLICT and the self-test RED, so this fails + * closed in both directions. + * + * ⛔ It is not a licence to route mixed files in general. Reach for + * `NOT_DRIVER_MANAGED` first; `mixed` is for the case where a hand merge provably + * cannot produce the right answer, and it buys back exactly the deferrals that are + * lossless — never the rest. + * * ## Why the owner is declared and not searched for (#13585) * * Until #13585 the verification read `packages/spec/package.json` alone, so an @@ -215,14 +240,46 @@ export const REGEN_ARTIFACTS = Object.freeze([ // against branch `4407/5770/…` and main `4284/5647/…`. A text merge cannot reach // that answer from either input, so this is a deferral-and-regenerate shape. // - // ⭐ And the deferral is safe in the direction that matters, which is the - // question `os-regen-merge.sh` raises about every path here — a driver that - // exits 0 trades a loud failure for a silent one unless something else still - // reddens. Here something does, on every PR: `check-system-context-census.mjs` - // runs in the required `Lint & Repo Gates` job with no `paths:` filter and on - // `merge_group`, it re-derives the census from the tree rather than reading the - // page back, and its scheduling is pinned by its own `--self-test` (#13646). The - // driver is therefore the cheap half here and never the only signal. + // ⚠️ #14064 CORRECTED the safety argument this row used to carry, and the + // correction is why it is the one row with a `mixed` field. The old text said the + // deferral is safe *because* `check-system-context-census.mjs` re-derives the + // census from the tree on every PR. That argument is TRUE and it covers HALF of + // what the driver discards. The gate re-derives the census and the anchors; it + // re-derives no prose, because the prose is derived from nothing. So the argument's + // domain and the risk surface do not coincide, and the gap is not theoretical: + // + // - The driver never writes `%A`, so the side left behind is OURS and the side + // dropped is always THEIRS — i.e. always main's already-landed work, never the + // branch's (measured on #14036: `git merge` exit 0, zero markers, output blob + // byte-identical to the branch head, all 16 of main's re-anchorings gone). + // - A PROSE-ONLY drop passes every check. Measured by deleting a 3-line + // anchor-free paragraph from the merged tree and running ten doc-family gates + // (`check-system-context-census`, `check:doc-anchors`, `check:doc-authoring`, + // `check:docs-single-h1`, `check:corpus-claim-drift`, `check:docs-audit-scope`, + // `check:role-word`, `check-doc-frontmatter`, `check-docs-section-name`, + // `check-doc-route-spelling`): ten exit 0 over silently deleted documentation. + // - It is not a rare path. main rewrites this page about every 75 minutes, so any + // PR touching it and older than an hour meets the driver by construction. + // + // ⭐ The routing itself is still RIGHT and #14064 does not undo it: the merged + // tree's correct anchors are on NEITHER side (measured on #13625 — five conflicted + // anchors resolve to 4408/5771/6019/6382/6575 against branch 4407/5770/… and main + // 4284/5647/…), so no text merge and no hand merge can reach them. Moving this row + // to NOT_DRIVER_MANAGED would buy the prose back by handing a page that conflicts + // hourly to a human who cannot resolve it correctly. The census over every routed + // path says that trade is worse than it looks: of the last 25 main commits to this + // page, 24 changed nothing but anchor line numbers — the case the driver handles + // correctly and cheaply — and exactly 1 touched prose. + // + // ⇒ So the row keeps its routing and declares WHEN the deferral is lossless. + // `mixed` names the equivalence: two revisions equal after + // `blankAnchorLineNumbers` differ only in the half `--fix` re-derives, and + // dropping either loses nothing. `git-merge-regen.mjs` refuses to defer silently + // when they are not equal, so the 1-in-25 prose case becomes a text merge or a + // loud conflict instead of a silent deletion, and the 24-in-25 anchor case keeps + // #13646's win untouched. This is the field to reach for when the NEXT mixed + // artifact arrives — a whole-file generator needs no `mixed`, because there is + // nothing on its page a regeneration cannot restore. // // No `readsDist`/`readsSchemaTree`: the census is an AST walk over `src/`, so a // merged tree is the whole prerequisite. `gen` cannot launder a POPULATION change @@ -234,6 +291,7 @@ export const REGEN_ARTIFACTS = Object.freeze([ gen: 'gen:system-context-census', check: 'check:system-context-census', owner: ROOT_OWNER, + mixed: 'line-anchors', }, // #13335 / #13731. The per-skill reference index — one row per `packages/spec` // source module, rendered whole by `gen:skill-refs`. Nine tracked files today, From c4d6d164a839908be94ef6d772d6767c0448ac3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 02:45:54 +0000 Subject: [PATCH 3/3] docs(devx): correct the falsified deferral-safety rationale in .gitattributes --- .gitattributes | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 460cc3ea5a..2b2e12110f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -86,12 +86,28 @@ # would defer the prose to OURS. See NOT_DRIVER_MANAGED for that entry. # # This is also the row where the header's own warning is answered rather than -# accepted: deferring is safe here because `scripts/check-system-context-census.mjs` -# still reddens on every PR from the required `Lint & Repo Gates` job — it -# RE-DERIVES the census from the tree, so it catches the stale anchors a merge -# leaves behind even when nothing conflicted, which is the majority case (#13625: -# 18 anchors stale, 5 marked). The driver removes hand-merge rounds; it is never -# the only signal. +# accepted: `scripts/check-system-context-census.mjs` still reddens on every PR from +# the required `Lint & Repo Gates` job — it RE-DERIVES the census from the tree, so +# it catches the stale anchors a merge leaves behind even when nothing conflicted, +# which is the majority case (#13625: 18 anchors stale, 5 marked). The driver removes +# hand-merge rounds; it is never the only signal. +# +# ⚠️ #14064 CORRECTED the sentence that used to open that paragraph — "deferring is +# safe here BECAUSE the census gate re-derives". The gate re-derives the census and +# the anchors. It re-derives no PROSE, because prose is derived from nothing, and +# this page is the one routed path that carries both. So the argument was true and +# its domain was half the risk surface: the driver drops a side WHOLE, and on this +# page that side can carry hand-written paragraphs the gate is constitutionally +# unable to miss. Measured, not reasoned — deleting a 3-line anchor-free paragraph +# and running ten doc-family gates returned ten exit 0 over deleted documentation. +# +# Routing this file is still RIGHT (the correct anchors are on neither side; nothing +# above changes). What #14064 added is the missing half: the row now declares +# `mixed: 'line-anchors'` in scripts/regen-artifacts.mjs, and the driver refuses to +# defer SILENTLY when the incoming side carries anything but anchor numbers — it +# text-merges instead, and conflicts loudly if that cannot be done. The cheap case +# stays cheap: of the last 25 main commits to this page, 24 changed nothing but +# anchor numbers. # # #13731 enumerated this file's blind spot and closed it: `check:merge-driver` now @@ -105,7 +121,10 @@ # reference indexes (#13335) and both halves of the react-blocks contract. Their # neighbours got the other answer for reasons recorded per path — the skill docs and # the AI skills guide are MIXED (a spliced block in hand-written prose, so a deferral -# would launder the prose), the two per-package test-typecheck ledgers are shrink-only +# would launder the prose; #14064 added a THIRD answer for the mixed file whose +# generated half no hand merge can reach — see `mixed` in scripts/regen-artifacts.mjs +# — but "not routed" remains the first answer to reach for), the two per-package +# test-typecheck ledgers are shrink-only # ratchets, the sdui lockstep record cannot be regenerated without an objectui # checkout, and the openapi/sbom outputs are gitignored so git never merges them. #