diff --git a/.gitattributes b/.gitattributes index 7cc23e8822b..6535aa494c5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -94,53 +94,18 @@ # merge-and-regenerate round. The cure is the one the header above records for # the three hottest artifacts; each gate sums its shards when it reads them. # -# The elevation census page joined at #13646, when it was a generated `file:line` -# anchor table whose correct merged values were on NEITHER side of a conflict -# (measured on #13625: five conflicted anchors resolved to 4408/5771/6019/6382/6575 -# against branch 4407/5770/… and main 4284/5647/…), so no text merge and no hand -# merge could reach them. ⚠️ It is routed as the FILE and NOT as -# `content/docs/permissions/**`: unlike `content/docs/references/**` above, which -# is generated whole, that directory is 22 hand-written prose pages around one -# generated one, and the glob 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: `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. -# While the page carried line anchors, that is what caught the stale anchors a merge -# left behind even when nothing conflicted, which was 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-derived 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 whose loss the gate is -# constitutionally unable to notice. Measured, not reasoned — deleting a 3-line -# anchor-free paragraph and running ten doc-family gates returned ten exit 0 over -# deleted documentation. -# -# #14064 kept the routing (the correct anchors were on neither side) and added the -# missing half: the row 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 stayed cheap: of the last -# 25 main commits to this page when that was measured, 24 changed nothing but anchor -# numbers. -# -# Today (since #15921) the page carries `path#symbol` anchors and no line numbers, -# so the `line-anchors` comparator is the IDENTITY on it (measured on #16612: -# `extractLineAnchors(page).length === 0`, `blankAnchorLineNumbers(page) === page`). -# Every real edit therefore takes the text-merge branch, or conflicts loudly, by -# design; the renumbering case is gone because there is nothing left to renumber. -# What the census gate catches after a merge is now a symbol anchor that no longer -# resolves, a population that no longer matches, or a declared count that no longer -# equals the census. ⛔ The `mixed` field stays: it is what routes this row through -# the lossless check at all, and deleting it sends the row to the unconditional -# deferral, the silent deletion #14064 closed (reproduced on #16612). The row's -# comment in scripts/regen-artifacts.mjs carries the measurements. +# The elevation census page (`content/docs/permissions/system-context.mdx`) was +# routed here from #13646 to #22927 and is not any more: nothing on it is +# generated. It joined as a `file:line` anchor table whose correct merged values +# were on NEITHER side of a conflict, became a MIXED row when #14064 measured that +# a whole-side deferral dropped its hand-written prose (`mixed: 'line-anchors'`), +# lost its line numbers to `path#symbol` anchors at #15921, and lost its last +# generated half — the census totals — to #22927's ruling. It text-merges like +# its prose neighbours now. ⚠️ What still catches a merge that leaves it wrong is +# `scripts/check-system-context-census.mjs`, which re-derives the census from the +# tree on every PR in the required `Lint & Repo Gates` job: a read no row anchors, +# or an anchor that no longer resolves. The `mixed` field and its comparator stay +# for the next mixed artifact (scripts/regen-artifacts.mjs, scripts/git-merge-regen.mjs). # # #13731 enumerated this file's blind spot and closed it: `check:merge-driver` now @@ -182,7 +147,6 @@ packages/spec/declaration-map/** merge=os-regen packages/spec/api-surface-signatures.json merge=os-regen docs/audits/2026-07-unknown-key-strictness-ledger.counts/** merge=os-regen content/docs/references/** merge=os-regen -content/docs/permissions/system-context.mdx merge=os-regen skills/*/references/_index.md merge=os-regen skills/objectstack-ui/references/react-blocks.md merge=os-regen scripts/platform-object-tenancy-census.json merge=os-regen diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b29da80d693..65e325a03d6 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,9 +10,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **130 -distinct sites across 20 packages**, and knowing three of those behaviours gives -no hint that the other hundred-and-four exist. Every documented app-side bug +because the flag is not one concept: it is a single boolean read at **over a +hundred distinct sites across some twenty packages**, and knowing three of those +behaviours gives no hint that the rest exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, and the gap was observable only by querying the resulting rows. @@ -26,23 +26,23 @@ context whenever one exists. -**Every anchor, and every count about the census population, is checked by CI.** +**Every anchor, and the population behind it, is checked by CI.** `scripts/check-system-context-census.mjs` re-runs the AST census over the whole repo on each PR and refuses the page when an anchor names a symbol its file no -longer declares, when a stated count about the population disagrees with the -census, or — the check that matters most — when the code holds an elevation read -that no row here anchors. Six raw text counts in -[Maintaining this table](#maintaining-this-table) are deliberately **not** -enforced, and say there when they were measured. +longer declares, or — the check that matters most — when the code holds an +elevation read that no row here anchors. The page states no totals: run +`node scripts/isystem-census.mjs` for a live count. The six raw text counts in +[Maintaining this table](#maintaining-this-table) are a dated snapshot, and say +there when they were measured. ⚠️ **What these anchors buy, and what they cost — read this before trusting a row.** Every anchor here is a `path#symbol` citation. Nothing encodes a position, so an unrelated edit above a site cannot rot one, and a repair is never mechanical: there are no numbers to renumber. The price is granularity. Several reads inside one function collapse onto one anchor, so **deleting a -whole symbol reds this page, and deleting one of several reads inside a symbol -that keeps at least one may not.** The gap is real, it is measured below, and it -is left open deliberately rather than hidden — see +whole symbol reds this page, and adding or deleting one of several reads inside +a symbol that keeps at least one does not.** The gap is real, it is priced +below, and it is left open deliberately rather than hidden — see [Maintaining this table](#maintaining-this-table). @@ -143,7 +143,8 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 130 sites**. +One of the largest consumers of the flag; +`node scripts/isystem-census.mjs --json` lists every site with its package. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -287,10 +288,11 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 130 read sites - in 20 packages. Splitting it is a breaking contract change across all of them. - (The ruling was taken when the census read 80 sites in 18 packages; the count - has grown, which strengthens rather than weakens the argument.) +- **Shipped semantics.** `isSystem` is a published contract read at over a + hundred sites across some twenty packages. Splitting it is a breaking contract + change across all of them. (The ruling was taken when the census read 80 sites + in 18 packages; the count has grown since, which strengthens rather than + weakens the argument.) - **No business pull.** No app has asked for the combinations a split would enable; the observed need was to *understand* the flag, which is what this page serves. @@ -315,26 +317,21 @@ The table's value is exhaustiveness, so it is built by census, not by recall — and the census is a committed instrument rather than someone's afternoon: ```bash -node scripts/isystem-census.mjs # the summary below +node scripts/isystem-census.mjs # the live totals, for the tree you run it on node scripts/isystem-census.mjs --json # every site, its kind and its package ``` -**Every number below marked ✅, plus the four sentences elsewhere on this page -that restate the same figures (the opening headline, the plugin-sharing share, -the #4707 ruling quote), is *generated*, never hand-maintained.** `pnpm -gen:system-context-census` (`node scripts/check-system-context-census.mjs ---fix`) re-derives the census and rewrites every declared count that has -drifted from it — the exact computation the check below already runs, applied -as a write instead of a comparison. ⛔ **Never hand-retype one of these -digits.** Two branches each independently — and correctly, for their own -tree — bumping the same sentence to the same number is exactly how this page -once went silently wrong: the edits are textually identical, git merges them -clean with no conflict, and the merged total is neither side's number -(#16919). Run the generator instead, against the tree you actually want -counted, and it fails loudly rather than writing a partial page if a sentence -no longer parses. `--fix` still cannot add or drop a **row** — a site that -arrived or vanished is a human's editorial call, same as always — it only -keeps the aggregate counts in lockstep with whatever rows exist. +**This page states no totals, and that is deliberate.** How many sites, in how +many packages and files, is derived data: it moves whenever a read arrives or +leaves, and a figure typed into hand-written prose is paid for at every merge. +Two branches that each add a read bump the same sentence and conflict on it — +or, bumping it to the same number, text-merge clean to a total neither tree +holds, which is how this page once went silently wrong (#16919). So the totals +are computed where they are read: the census prints them for the tree you run +it on, and the check below prints them again on every pass. What CI holds is +the register itself — every elevation read anchored, every anchor resolving — +not a sum of it. A site that arrived or vanished is a human's editorial call: +add or drop its row. **⛔ Not a `grep`.** A text scan is where a census *starts* and it cannot be where one ends: it returns prose inside comments and strings, the three unrelated @@ -349,31 +346,40 @@ a **read** (a table row here), a **declaration**, an object-literal or type Corpus: tracked `.ts` / `.tsx` / `.mts` / `.cts` under `packages/` and `examples/`, excluding `dist/`. A file counts as a test when its path carries -`.test.` / `.spec.` or a `tests/` / `__tests__/` / `qa/` segment. Every number -below was produced by that run. The **CI** column says which of them a check -still holds equal to the census on every pull request: - -| Measurement | Count | CI | -|:---|--:|:--:| -| Lines carrying `isSystem` in the corpus | 1811 | — | -| — in tests | 1013 | — | -| — in non-test sources | 798 | — | -| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | -| — parsed as a declaration | 29 | ✅ | -| — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 135 | ✅ | -| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | -| — the remainder: text inside comments and string literals | 358 | — | -| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **130** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 127 | ✅ | -| — carry the flag onward only (rows 62–64 above) | 3 | ✅ | -| Packages containing at least one elevation read | **20** | ✅ | -| Files containing at least one elevation read | 59 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 110 | ✅ | -| — of those files, the ones holding more than one read in one symbol | 9 | ✅ | - -The six rows marked — are a **dated decomposition, not a live claim**: they were +`.test.` / `.spec.` or a `tests/` / `__tests__/` / `qa/` segment. The census +narrows that corpus in the order below. The raw text it starts from is kept here +as a dated snapshot: + +| Raw text | Count | +|:---|--:| +| Lines carrying `isSystem` in the corpus | 1811 | +| — in tests | 1013 | +| — in non-test sources | 798 | +| Appearances of the bare identifier `isSystem` in non-test sources | 813 | +| — parsed as an object-literal / type key (producers and option objects) | 310 | +| — the remainder: text inside comments and string literals | 358 | + +The rest of those appearances are what the census classifies, and the page +restates none of its figures: + +| Census step | What it holds | +|:---|:---| +| Appearances parsed as a declaration | every declaring slot — see below | +| Appearances parsed as a property **read** | every read, before the collision is subtracted | +| Appearances parsed in some other syntactic position | a local, a cast, a conditional | +| Of those reads: reads of one of the unrelated metadata fields | subtracted — the collision this page opens with | +| Of those reads: reads of `ExecutionContext.isSystem` | **the census answer**: every one sits in a symbol a row above anchors | +| — behaviour-bearing (rows 1–61 above) | sections 1 to 5 | +| — carry the flag onward only (rows 62–64 above) | section 6 | + +`node scripts/isystem-census.mjs` prints how many appearances fell in each +syntactic slot, the subtraction, the answer and the packages and files that +hold it; +`node scripts/check-system-context-census.mjs` prints the answer again on every +pass, with the distinct symbols those reads live in and how many files hold more +than one read in one symbol. + +The six raw-text rows are a **dated decomposition, not a live claim**: they were measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count raw *text* over the whole corpus — every line, identifier appearance, object-literal key and prose mention of the string `isSystem` under `packages/` @@ -384,25 +390,23 @@ elevation: a test that mentions the flag, a seed object carrying un-landable, because CI scores a **merge** with `main` and the merge queue re-derives that merge against a newer `main` on every attempt: three unrelated merges moved these six numbers eight times in one night, while the census -population — 109 sites in 20 packages across 45 files — did not move once. They -are kept because they are how you get from a raw `grep` to 109, which is the -whole argument of this section; they are unenforced because a count of lines in -test files certifies nothing. ⛔ Do not re-add them to `DECLARED_COUNTS` — a -self-test case in the gate refuses that by name. Re-measure them with +population — 109 sites in 20 packages across 45 files at the time — did not +move once. They are kept because they are how far a raw `grep` sits from the +census answer, which is the whole argument of this section; they are unenforced +because a count of lines in test files certifies nothing. Re-measure them with `node scripts/isystem-census.mjs` when you want them current, and move the date. -**What the enforced declarations row counts.** Not the three field declarations -above — those are three *distinct fields* that happen to share a name, and only -the first is elevation. This row counts every position where the parser puts the -identifier in a **declaring** slot: those three, plus the structural type literals -that restate `ExecutionContext.isSystem`'s shape inline rather than importing it -(`{ isSystem: true; tenantId?: string }`, `context?: { isSystem?: boolean }`, and -the `get isSystem()` accessor on the engine's context wrapper). A restatement is -a producer's declaration of the shape it will build, never a read, so a new one -moves this count and moves nothing else on this page — the census's read -population, the anchored rows above, and the packages and files totals all stay -where they are. The most recent arrival is the scoped -seed context threaded into the org-admin permission-set lookup in +**What the census counts as a declaration.** Not only the three field +declarations above — those are three *distinct fields* that happen to share a +name, and only the first is elevation. It counts every position where the +parser puts the identifier in a **declaring** slot: those three, plus the +structural type literals that restate `ExecutionContext.isSystem`'s shape inline +rather than importing it (`{ isSystem: true; tenantId?: string }`, +`context?: { isSystem?: boolean }`, and the `get isSystem()` accessor on the +engine's context wrapper). A restatement is a producer's declaration of the +shape it will build, never a read, so a new one moves the census's declaration +count and nothing on this page — the read population and the anchored rows +above stay where they are. The most recent arrival is the scoped seed context threaded into the org-admin permission-set lookup in `packages/plugins/plugin-security/src/auto-org-admin-grant.ts`, so that read resolves against the granting organization's own catalog row rather than an organization-less one (#11670). ⛔ Cited as a FILE deliberately, with no @@ -434,8 +438,8 @@ same resolver, and the same registration shape, that holds `docs/adr/**`. Renaming a symbol is now a loud red instead of a silent misdirection. ⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor -cannot say WHICH read inside a function it means, and **9** of the **59** -anchored files hold more than one read inside a single symbol. So the population +cannot say WHICH read inside a function it means, and several of the anchored +files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must equal the set of symbols the census finds reads in. Two consequences, and the @@ -443,21 +447,23 @@ second one is a hole: - **Delete a whole symbol and this page reds** — the anchor stops resolving and the symbol set stops matching, in the same run. -- **Delete one of several reads inside a symbol that keeps at least one, and the - symbol set does not move, so it may not red.** The previous edition's line +- **Add or delete one of several reads inside a symbol that keeps at least one, + and the symbol set does not move, so it does not red.** While this page stated + totals, the site count moved on that edit — but the repair it prescribed was a + generator rewriting the number, never a row. The previous edition's line numbers did close this one: both reads lost in the last drift lived inside `callerContext()` helpers that still exist under the same names. Closing it - again means a span-aware resolver and per-read disambiguation in those nine - files; that is its own card, and it is deliberately not folded in here. + again means a span-aware resolver and per-read disambiguation in those files; + that is its own card, and it is deliberately not folded in here. Resolving anchors is nevertheless the *second* check, not the first. ⭐ **A gate that only checks what the page already says can never find what the page failed to say.** Re-resolving every anchor of the edition before last would have passed while it was missing 32 sites and its headline was 29 too low. So the load-bearing direction runs census → page: every elevation read in the code must -be anchored here, and a site that vanishes from the code takes the census total -with it — the site, package and file counts above are census-derived — which is -what makes a row describing a protection that no longer exists fail. +be anchored here, and a symbol whose last read vanishes from the code leaves an +anchor that names no read site, which is what makes a row describing a +protection that no longer exists fail. Four checks run, in `scripts/check-system-context-census.mjs`: @@ -465,11 +471,12 @@ Four checks run, in `scripts/check-system-context-census.mjs`: |:---|:---| | **Population** | a file holding an elevation read with no anchor here, or a symbol holding one that no anchor here names | | **Resolution** | an anchor naming no tracked file, or naming a symbol that file does not declare — and any surviving line number, which is no longer an anchor form | -| **Counts** | any **census-derived** number above that disagrees with the census — including the count-sentence wording, so the check cannot go quietly vacuous. The six raw text counts are exempt by design, but their rows must still be present and dated | | **Classification** | an anchor that is not a read site and is not a declared non-read citation | +| **Dated snapshot** | a raw-text row above reworded off the page, or its measurement date dropped — the numbers themselves are never compared | A new read of `ExecutionContext.isSystem` belongs in this table in the same PR -that introduces it — CI will say so if it is not. +that introduces it — CI will say so when it lands in a symbol no row anchors +yet; inside a symbol that already has one, only the reviewer will. ## Related diff --git a/package.json b/package.json index 67a67a3b15d..5b945f18506 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,6 @@ "check:stall-guard-budget": "node scripts/check-stall-guard-budget.mjs --self-test && node scripts/check-stall-guard-budget.mjs", "check:stall-guard-headroom": "node scripts/measure-stall-guard-headroom.mjs --self-test", "check:swallow-census-controls": "node scripts/measure-durability-swallow-family.mjs --self-test=gated", - "gen:system-context-census": "node scripts/check-system-context-census.mjs --fix", "check:system-context-census": "node scripts/check-system-context-census.mjs --self-test && node scripts/check-system-context-census.mjs", "gen:platform-object-tenancy-census": "node scripts/platform-object-tenancy-census.mjs --write", "check:platform-object-tenancy-census": "node scripts/check-platform-object-tenancy-census.mjs --self-test && node scripts/check-platform-object-tenancy-census.mjs" diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs index 2e94f2f985c..dd29cbc4f71 100644 --- a/scripts/check-system-context-census.mjs +++ b/scripts/check-system-context-census.mjs @@ -7,7 +7,7 @@ * * node scripts/check-system-context-census.mjs * node scripts/check-system-context-census.mjs --self-test - * node scripts/check-system-context-census.mjs --fix # regenerates declared COUNTS; anchors still need a human -- see below + * node scripts/check-system-context-census.mjs --fix # RETIRED: refuses, exit 2 -- nothing on the page is generated * * That page declares itself "the authority" for every platform behaviour keyed off * `ExecutionContext.isSystem`, and says it is "built by census over the whole repo, @@ -33,8 +33,8 @@ * carry an anchor. The other direction (PAGE -> CENSUS) is worth having and cheap, * but it is the second gate, not the first. * - * Deletions are caught here by the counts, which are census-derived: lose a site - * and the page's declared total stops being true. + * Deletions are caught at SYMBOL granularity: a symbol whose last read is gone + * leaves an anchor the census no longer calls a read site (check D below). * * ## ⭐ Why the anchors are `path#symbol` and no longer `path:line` (#15921) * @@ -88,40 +88,48 @@ * census plus `NON_READ_ANCHORS` require -- so the two counts * are equal by construction and a difference names the symbol * rather than only the number. ⭐ This is the mandatory one. - * C COUNTS every CENSUS-DERIVED number the page states equals the census. - * A pattern that matches NOTHING is an error, so a reworded page - * cannot silently stop being checked. The page's whole-corpus - * TEXT counts are deliberately NOT compared -- see the next - * section -- but they are still required to be present and dated. + * C DATED TEXT the page's whole-corpus TEXT counts are present and dated, + * and never compared to anything -- see the next section. * D CLASSIFICATION a symbol anchor the census does not call an elevation read * must be a declared `NON_READ_ANCHORS` row, and that row's * symbol must still be declared by its file. * - * ## ⭐ What is enforced, and why the text decomposition is NOT - * - * The page carries two kinds of number and they behave nothing alike. - * - * CENSUS-DERIVED properties of the population this page certifies: the - * elevation read sites, their packages and files, the total - * property reads the census subtracts from, the documented - * collision subtraction, and the page's own split of the sites - * into behaviour-bearing and carry-onward rows. These move - * only when the elevation contract moves -- which is precisely - * when this page must be edited anyway. ⭐ ALL of these stay - * enforced. + * ## ⭐ The page states no totals (#22927), and the text decomposition is dated + * + * The page used to carry two kinds of number. CENSUS-DERIVED totals -- the + * elevation read sites, their packages and files, the role counts, the collision + * subtraction, the behaviour/carry-onward split -- were held equal to the census + * and regenerated by `--fix`. ⛔ They are GONE, by ruling: a derived total typed + * into hand-written prose is paid for at every merge (two branches that each add + * a read bump the same sentence and conflict on it, or, bumping it to the same + * number, text-merge clean to a sum neither tree holds), and no reader needs it + * that `node scripts/isystem-census.mjs` and this gate's own pass line do not + * already serve. The register stays enforced -- POPULATION, RESOLUTION and + * CLASSIFICATION are untouched. + * + * ⚠️ What the totals did catch, measured on 1bd612bd6 so it is not rediscovered + * as a regression: a read ADDED to or REMOVED from a symbol the page already + * anchors (`packages/objectql/src/engine.ts#insert`, two reads, was the probe) + * moved the site total and reddened five `[declared-count]` sentences -- and this + * gate's own prescription for that red was the generator, which rewrote the + * numbers and required no row. That tripwire went with the numbers. The + * precision it stood in for is the one the next sections price; closing it is a + * span-aware resolver, its own card. * * WHOLE-CORPUS TEXT how many LINES carry the string `isSystem` anywhere under * `packages/` and `examples/` (tests included), how many times * the bare identifier appears, how many of those the parser * puts in an object-literal key, and the prose remainder. - * ⛔ NOT enforced: `UNENFORCED_TEXT_COUNTS` carries the six, - * with the measurement that moved them there. + * ⛔ NEVER compared: `UNENFORCED_TEXT_COUNTS` carries the six, + * with the measurement that put them there. The page keeps + * them as a dated snapshot of how far a `grep` sits from the + * census answer. * * The split is not a tolerance. Nothing about the CONTRACT stopped being checked: - * a read site with no row, a deleted site, a rotted anchor and an empty census all - * still fail. What stopped being checked is a set of numbers about a population - * the page does not certify -- and whose churn, measured, was blocking the page - * from ever landing. + * a read site with no row, a deleted symbol, a rotted anchor and an empty census + * all still fail. What stopped being checked is a set of numbers about a + * population the page does not certify -- and whose churn, measured, was blocking + * the page from ever landing. * * ## Why `NON_READ_ANCHORS` is keyed by SYMBOL * @@ -154,17 +162,18 @@ * undeclared overlap reads as a row that excuses an anchor when it does not, and a * declared overlap that has ended is a row nobody re-examined. * - * ## ⛔ `--fix` no longer rewrites anything, and that is the point + * ## ⛔ `--fix` is retired, and refuses rather than vanishing * - * It used to re-anchor a pure line shift, which was the common repair: a file grew - * an import, every anchor into it moved by one, and a mechanical remap was both - * safe and necessary. Symbol anchors do not shift, so that repair has no subject. + * It had two lives. It re-anchored a pure line shift until symbol anchors gave + * that repair no subject (#15921), then rewrote the declared counts (#16919) until + * the counts left the page (#22927). Nothing on the page is generated any more, + * so `gen:system-context-census` and the page's `merge=os-regen` route went too. * ⛔ The flag is NOT silently accepted -- a `--fix` that writes nothing and exits 0 - * reads exactly like a repair that worked. It prints what it did not do and why, - * and then returns this gate's ordinary verdict, so `gen:system-context-census` - * stays wired and stays honest. + * reads exactly like a repair that worked, and a branch cut before #22927 still + * carries a merge-driver row that names the generator. It refuses with exit 2 and + * says why. * - * ⇒ Every red here is now a HUMAN edit: a symbol was renamed (update the anchor), + * ⇒ Every red here is a HUMAN edit: a symbol was renamed (update the anchor), * a read arrived or vanished (write or delete the row), or a citation moved out of * the symbol that held it. * @@ -211,7 +220,7 @@ import { createHash } from 'node:crypto'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -250,12 +259,10 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the deletion shape: the row stands, the site is gone': 3, '⭐ RESOLUTION is delegated, and a sweep that did not run is a REFUSAL': 3, 'ledger': 5, - 'counts': 6, - '⭐ CRITERION: enforced means CENSUS-DERIVED, pinned over the REAL lists': 2, - 'the same criterion, behaviourally, on one page': 3, + 'the dated decomposition: present and dated, never compared': 3, '⛔ and the half that must NOT have moved: the contract still reds': 2, 'absence is loud': 1, - '⛔ --fix regenerates declared COUNTS only, never anchors, and says so': 8, + '⛔ --fix is retired: the gate writes nothing, and the flag refuses': 4, '⭐ THE RULED RED-FIRST PAIR: a symbol rename REDS, a pure line move does NOT': 5, '⭐ the precision this trades away, pinned so nobody rediscovers it as a bug': 2, 'the refusal has to SHOW its work (both counts, the symbol, the file)': 2, @@ -266,8 +273,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ }); // DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 18; +// zeroing it, so the roster's own size is pinned too. (18 -> 16 when #22927 +// retired the counts: 'counts' and the CRITERION battery tested only them.) +const SELF_TEST_BATTERY_FLOOR = 16; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -573,13 +581,11 @@ export const NON_READ_ANCHORS = [ * one entry below, and an unclaimed one is a refusal naming the line. Likewise every * `row N` inside a `why:` must carry its seam, or the ledger row is refused. * - * ## ⛔ Why `--fix` does not renumber prose + * ## ⛔ Why nothing renumbers prose * - * `--fix` rewrites ANCHORS, which live on the page this gate maintains. Renumbering - * prose would mean writing `content/docs/permissions/system-context.mdx` as well -- - * a `merge=os-regen` routed file held by several open PRs at the time this check was - * written. Refuse-only here, deliberately: the repair is a human edit, and the - * `--fix` leg for prose is a follow-up once the page is free. + * Refuse-only, deliberately: a falsified reference is repaired by a human who + * reads the sentence, and this gate writes nothing -- `--fix` is retired (see the + * header). */ /** @@ -915,237 +921,9 @@ export function checkRowReferences({ pageText, ledger = NON_READ_ANCHORS, pageRe } /** - * Numbers the page states ABOUT THE CURRENT TREE, each tied to the census value it - * must equal. - * - * ⚠️ Deliberately excluded: every figure describing the PREVIOUS edition (111 - * anchors, 101 rotted, 41 ambiguous, the `grep -c` 64, "80 sites across 18 - * packages"). Those are history, they are true of a tree that no longer exists, and - * a gate that "corrected" them would be rewriting the record. - * - * ⛔ Also deliberately excluded, and for a different reason: the six WHOLE-CORPUS - * TEXT counts, which live in `UNENFORCED_TEXT_COUNTS` below. Everything that - * remains here is CENSUS-DERIVED -- it changes only when the elevation population - * changes. ⭐ That invariant is pinned by a self-test case (`CRITERION`), which - * drifts a fixture census's text figures and requires every entry in this list to - * hold still: re-add a text count here and the self-test names it. - * - * `pattern` must have exactly one capture group -- the number -- and must match at - * least once. A pattern that matches nothing is an ERROR: it means the page was - * reworded out from under the check, which is how a counts gate goes quietly - * vacuous. - */ -/** - * How many distinct symbols the census's read sites live in — the population the - * page can actually ANCHOR, which is smaller than the site count wherever several - * reads share a symbol. - */ -function distinctSymbolCount(census) { - let total = 0; - for (const entry of symbolPopulation(census).values()) total += entry.symbols.size; - return total; -} - -/** - * How many files hold more than one read inside a single symbol — the size of the - * precision this page trades away, kept enforced so the sentence that prices it - * cannot quietly stop being true in either direction. - */ -function collapsingFileCount(census) { - let files = 0; - for (const entry of symbolPopulation(census).values()) { - if (entry.symbols.size + (entry.fileLevel ? 1 : 0) !== entry.sites) files += 1; - } - return files; -} - -export const DECLARED_COUNTS = [ - { - id: 'headline-sites', - pattern: /a single boolean read at \*\*(\d+)\s*\n?\s*distinct sites/, - value: (c) => c.sites.length, - why: 'the headline claim in the opening section', - }, - { - id: 'headline-packages', - pattern: /distinct sites across (\d+) packages\*\*/, - value: (c) => c.packages.length, - why: 'the headline package count', - }, - { - id: 'sharing-share', - pattern: /The largest single consumer — \*\*(\d+) of the \d+ sites\*\*/, - value: (c) => c.sites.filter((s) => s.package.endsWith('plugin-sharing')).length, - why: "section 3's claim about plugin-sharing's share", - }, - { - id: 'sharing-total', - pattern: /The largest single consumer — \*\*\d+ of the (\d+) sites\*\*/, - value: (c) => c.sites.length, - why: 'the denominator of the same claim', - }, - { - id: 'table-declarations', - pattern: /\| — parsed as a declaration \|\s*(\d+) \|/, - value: (c) => c.roleCounts.declaration, - why: 'the decomposition table: declarations', - }, - { - id: 'table-reads', - pattern: /\| — parsed as a property \*\*read\*\* \|\s*(\d+) \|/, - value: (c) => c.roleCounts.read, - why: 'the decomposition table: property reads', - }, - { - id: 'table-other', - pattern: /\| — parsed in some other syntactic position[^|]*\|\s*(\d+) \|/, - value: (c) => c.roleCounts.other, - why: 'the decomposition table: everything else the parser saw', - }, - { - id: 'table-unrelated-reads', - pattern: /\| Of those reads: reads of one of the unrelated metadata fields \|\s*(\d+) \|/, - value: (c) => c.nonElevationReads.length, - why: 'the decomposition table: the collision subtraction', - }, - { - id: 'table-elevation-reads', - pattern: /\| Of those reads: reads of `ExecutionContext.isSystem` \|\s*\*\*(\d+)\*\* \|/, - value: (c) => c.sites.length, - why: 'the decomposition table: the census answer', - }, - { - id: 'table-carry-onward', - pattern: /\| — carry the flag onward only \(rows \d+–\d+ above\) \|\s*(\d+) \|/, - value: (c, page) => carryOnwardRowCount(page), - why: "the decomposition table: how many sites only propagate the flag — held to section 6's own row count", - }, - { - id: 'table-behaviour-bearing', - pattern: /\| — behaviour-bearing \(rows \d+–\d+ above\) \|\s*(\d+) \|/, - value: (c, page) => c.sites.length - carryOnwardRowCount(page), - why: 'the decomposition table: the remaining sites, derived so the two halves must sum to the census', - }, - { - id: 'table-packages', - pattern: /\| Packages containing at least one elevation read \|\s*\*\*(\d+)\*\* \|/, - value: (c) => c.packages.length, - why: 'the decomposition table: package count', - }, - { - id: 'table-files', - pattern: /\| Files containing at least one elevation read \|\s*(\d+) \|/, - value: (c) => c.files.length, - why: 'the decomposition table: file count', - }, - { - id: 'table-symbols', - pattern: /\| — the distinct symbols those reads live in — what this page anchors \|\s*(\d+) \|/, - value: (c) => distinctSymbolCount(c), - why: 'the decomposition table: what this page can actually anchor, after the collapse', - }, - { - id: 'table-collapsing-files', - pattern: /\| — of those files, the ones holding more than one read in one symbol \|\s*(\d+) \|/, - value: (c) => collapsingFileCount(c), - why: 'the decomposition table: the size of the declared precision loss', - }, - { - id: 'precision-collapsing-files', - pattern: /\*\*(\d+)\*\* of the \*\*\d+\*\*\s*\n?\s*anchored files hold more than one read/, - value: (c) => collapsingFileCount(c), - why: 'the prose that prices the precision loss where a reader meets the anchors', - }, - { - id: 'precision-anchored-files', - pattern: /\*\*\d+\*\* of the \*\*(\d+)\*\*\s*\n?\s*anchored files hold more than one read/, - value: (c) => c.files.length, - why: 'the denominator of that same sentence', - }, - { - id: 'ruling-sites', - pattern: /`isSystem` is a published contract with (\d+) read sites/, - value: (c) => c.sites.length, - why: "the #4707 ruling's premise -- it is quoted as a live count, so it must stay one", - }, - { - id: 'ruling-packages', - pattern: /read sites\s*\n?\s*in (\d+) packages\./, - value: (c) => c.packages.length, - why: "the ruling's package count", - }, -]; - -/** - * The WRITE half of `DECLARED_COUNTS` (#16919 direction 1). - * - * `evaluate()`'s COUNTS check (below) already computes, for every declared - * sentence, exactly the value it should hold — `declared.value(census, page)` - * — and only ever uses it to compare. Two branches independently hand-typing - * the same freshly-computed number into the same sentence text-merge clean and - * silently wrong on the SUM: main and a sibling PR each bumped the same seven - * sentences 106 → 107 for two different new reads, and the merged tree held - * 108. The fix in that shape is never "type the right number" — it is "stop - * typing it": this function performs the identical `pattern`/`value` lookup - * COUNTS already runs, and writes the result back instead of only comparing. - * - * Pure — a text in, a text out, plus what changed and what could not be - * derived. The caller decides whether to persist `text` or to refuse on a - * non-empty `errors`; nothing here touches the filesystem, so `--self-test` - * can drive it on a fixture exactly as it drives `evaluate()`. - * - * ⛔ Deliberately narrow, same rule as `check:generated --fix` elsewhere in - * this repo (see AGENTS.md, "Touched `packages/spec`?"): only a MISMATCHED - * count is rewritten, so a page where every count already agrees with the - * census comes back byte-identical — idempotent by construction, not merely - * in practice (`--self-test`'s IDEMPOTENCE case proves it on the real page). - * `UNENFORCED_TEXT_COUNTS` is deliberately NOT in scope: those six rows count - * whole-corpus TEXT, not the elevation contract, and are unenforced by design - * (see that export) — regenerating them would silently start enforcing a - * population this gate has already measured and rejected as noise. - * - * @param {{ pageText: string, census: object, declaredCounts?: typeof DECLARED_COUNTS }} args - * @returns {{ text: string, rewrites: {id: string, from: string, to: string}[], errors: string[] }} - */ -export function regenerateDeclaredCounts({ pageText, census, declaredCounts = DECLARED_COUNTS }) { - let text = pageText; - const rewrites = []; - const errors = []; - for (const declared of declaredCounts) { - // `d` adds match INDICES (Node >=16) so only the captured digits are - // spliced out -- never the surrounding sentence, which stays hand-written - // prose and must survive byte-for-byte on every run that changes nothing. - const flags = declared.pattern.flags.includes('d') ? declared.pattern.flags : `${declared.pattern.flags}d`; - const re = new RegExp(declared.pattern.source, flags); - const match = re.exec(text); - if (!match) { - errors.push( - `[count-pattern-unmatched] the page no longer carries the \`${declared.id}\` sentence ` + - `(${declared.why}) -- nothing was rewritten for it. Update the pattern together with the wording.` - ); - continue; - } - const actual = declared.value(census, text); - if (!Number.isInteger(actual) || actual < 0) { - errors.push( - `[count-underivable] \`${declared.id}\` could not be derived (${declared.why}) -- the page ` + - 'structure it reads is gone; nothing was rewritten for it.' - ); - continue; - } - const stated = match[1]; - const replacement = String(actual); - if (stated === replacement) continue; - const [start, end] = match.indices[1]; - text = text.slice(0, start) + replacement + text.slice(end); - rewrites.push({ id: declared.id, from: stated, to: replacement }); - } - return { text, rewrites, errors }; -} - -/** - * ⛔ The six numbers this gate deliberately does NOT hold to the census, listed - * here so that stays a decision instead of an omission. + * ⛔ The six numbers this gate deliberately does NOT hold to the census -- the + * only figures the page still carries -- listed here so that stays a decision + * instead of an omission. * * Each one counts WHOLE-CORPUS TEXT: lines carrying the string `isSystem` * anywhere under `packages/` and `examples/` (tests included), appearances of the @@ -1154,7 +932,7 @@ export function regenerateDeclaredCounts({ pageText, census, declaredCounts = DE * certifies -- a test that mentions the flag, a seed object carrying * `isSystem: true`, or a comment moves them. * - * ## Why they are here rather than in `DECLARED_COUNTS`, measured + * ## Why they are never compared, measured * * They were enforced, and enforcement did not survive contact with the repo. CI * scores a pull request's MERGE with `main`, and the merge queue re-derives that @@ -1173,16 +951,17 @@ export function regenerateDeclaredCounts({ pageText, census, declaredCounts = DE * figure held flat -- 109 sites, 20 packages, 45 files, 6 ledger subtractions, and * the role counts 21 declarations / 115 reads / 9 other -- across db39dfc1c9, * 8a483b38b8, ca1965f2b5 and the merged tree. The population did not move once - * while the text counts moved eight times. That is the whole argument: the - * enforced set is the one that changes when the CONTRACT changes. + * while the text counts moved eight times. That was the argument for enforcing + * only the census-derived set; #22927 then took that set off the page as well + * (see the header), so what this gate enforces is the register, never a number. * * ## ⛔ What this is NOT * * It is not a tolerance and it is not a narrowing of the contract. The POPULATION - * check (every elevation read must be anchored), the RESOLUTION check, the - * CLASSIFICATION check and the census-derived counts are untouched, and an empty - * census still refuses. What narrows is the set of numbers the page DECLARES about - * a population it does not certify. + * check (every elevation read must be anchored), the RESOLUTION check and the + * CLASSIFICATION check are untouched, and an empty census still refuses. What + * narrows is the set of numbers the page DECLARES about a population it does not + * certify. * * Each row is still required to MATCH: a pattern that stops matching is an error * exactly as it is for an enforced count, so the rows cannot be reworded off the @@ -1244,28 +1023,6 @@ export const UNENFORCED_MEASURED_AT = { why: 'the dated marker on the unenforced decomposition', }; -/** - * How many numbered rows section 6 ("Reads that only carry the flag onward") has. - * - * The page splits its 109 sites into behaviour-bearing rows and carry-onward rows. - * Neither number is derivable from the census alone -- which row is which is the - * page's own editorial call -- so the split is anchored to the thing that IS - * mechanical: the size of that table. Zero rows is a refusal, not a zero: it means - * the section was renamed and the split stopped being checked. - * - * @param {string} pageText - * @returns {number} - */ -export function carryOnwardRowCount(pageText) { - const start = pageText.indexOf('### 6.'); - if (start === -1) return -1; - const rest = pageText.slice(start); - const end = rest.indexOf('\n---'); - const section = end === -1 ? rest : rest.slice(0, end); - const rows = section.split('\n').filter((line) => /^\|\s*\d+\s*\|/.test(line)); - return rows.length; -} - /** * Resolve every `NON_READ_ANCHORS` row against its file, through the SHARED * resolution rule. @@ -1345,7 +1102,6 @@ export function evaluate({ readFile, sweep, ledger = NON_READ_ANCHORS, - declaredCounts = DECLARED_COUNTS, unenforcedCounts = UNENFORCED_TEXT_COUNTS, measuredAt = UNENFORCED_MEASURED_AT, pageRowReferences = PAGE_ROW_REFERENCES, @@ -1493,34 +1249,7 @@ export function evaluate({ ); } - // ── C. COUNTS ─────────────────────────────────────────────────────────────── - for (const declared of declaredCounts) { - const match = declared.pattern.exec(pageText); - if (!match) { - problems.push( - `[count-pattern-unmatched] the page no longer carries the \`${declared.id}\` sentence ` + - `(${declared.why}) -- this gate stopped checking a number nobody removed. Update the ` + - 'pattern together with the wording.' - ); - continue; - } - const stated = Number(match[1]); - const actual = declared.value(census, pageText); - if (!Number.isInteger(actual) || actual < 0) { - problems.push( - `[count-underivable] \`${declared.id}\` could not be derived (${declared.why}) -- the page ` + - 'structure it reads is gone. Fix the reader together with the page.' - ); - continue; - } - if (stated !== actual) { - problems.push( - `[declared-count] \`${declared.id}\` says ${stated}, the census says ${actual} (${declared.why}).` - ); - } - } - - // ── C2. THE UNENFORCED DECOMPOSITION: present and dated, never compared ────── + // ── C. THE DATED DECOMPOSITION: present and dated, never compared ─────────── // ⛔ The values below are NOT checked against the census -- see // UNENFORCED_TEXT_COUNTS for the measurement that put them there. What IS // checked is that the rows still exist and still say when they were true, so @@ -1586,32 +1315,27 @@ function readFileAt(root) { } /** - * ⛔ ANCHORS have nothing to repair, and `--fix` says so instead of exiting 0 in - * silence about that half. - * - * Before #15921 this rewrote line numbers after a pure shift, which was the - * common repair and a real one. Symbol anchors encode no position, so the shift - * that repair existed for cannot happen: an edit above a site moves nothing this - * page writes. A remaining anchor red is a human edit -- a rename, an arrived - * read, a vanished one -- and none of them is mechanically derivable from the - * tree. COUNTS are the other half, and `run()` handles those separately below - * (#16919) -- this function is deliberately scoped to what stays a human edit. - * - * ⭐ The flag stays RECOGNISED on purpose. `gen:system-context-census` and - * `scripts/regen-artifacts.mjs` both name it, and a flag that silently became a - * no-op would leave both reading as a working regeneration path. This prints what - * it did not do, then returns the ordinary verdict. + * ⛔ `--fix` is RETIRED, and says so instead of exiting 0 in silence. + * + * Nothing on the page is generated: its anchors are symbols, which encode no + * position to repair (#15921), and it states no census totals (#22927). A + * recognised flag that quietly ran the check would read as a regeneration that + * worked -- to a person, and to a branch cut before #22927 whose merge-driver row + * still names `gen:system-context-census`. So it refuses, before any census runs. */ -function reportNoAnchorFix() { - process.stdout.write( - 'check-system-context-census --fix: anchors have nothing to rewrite — this page carries no line\n' + - ' numbers. Anchors are `path#symbol` (scripts/symbol-anchors.mjs), so an unrelated edit above a\n' + - ' site cannot rot one and there is no mechanical repair to apply there. A remaining anchor red is\n' + - ' a human edit: a renamed symbol, a read that arrived, or a read that vanished (add its row).\n' +const FIX_RETIRED_EXIT = 2; + +function refuseRetiredFix() { + process.stderr.write( + 'check-system-context-census --fix: RETIRED -- nothing on the page is generated, so there is\n' + + ' nothing to rewrite. Anchors are `path#symbol` (scripts/symbol-anchors.mjs) and the page states\n' + + ' no census totals: `node scripts/isystem-census.mjs` prints them. Every red here is a human\n' + + ' edit -- a renamed symbol, or a read that arrived or vanished (add or drop its row).\n' ); + return FIX_RETIRED_EXIT; } -function run({ fix = false } = {}) { +function run() { const readFile = readFileAt(ROOT); let pageText; try { @@ -1633,49 +1357,14 @@ function run({ fix = false } = {}) { return 1; } - if (fix) { - // ── COUNTS: the one half of `--fix` that IS mechanical (#16919 direction 1) ── - // - // Reuses the exact `DECLARED_COUNTS` computation the COUNTS check below - // runs, as a write instead of a comparison -- see `regenerateDeclaredCounts`. - // A count that cannot be derived or located refuses loudly (`errors`) rather - // than writing a partial page: a generator that writes six of seven numbers - // and silently skips the seventh is worse than one that writes none. - const { text, rewrites, errors } = regenerateDeclaredCounts({ pageText, census }); - if (errors.length > 0) { - for (const error of errors) process.stderr.write(`::error::${error}\n`); - process.stderr.write( - `\ncheck-system-context-census --fix: ${errors.length} declared count(s) could not be ` + - 'regenerated -- the page wording changed out from under the pattern that reads it. Fix the ' + - 'wording and the pattern together (by hand), then re-run --fix.\n' - ); - return 1; - } - if (rewrites.length > 0) { - writeFileSync(join(ROOT, PAGE), text, 'utf8'); - pageText = text; - process.stdout.write( - `check-system-context-census --fix: regenerated ${rewrites.length} declared count(s) from the ` + - `census: ${rewrites.map((r) => `${r.id} ${r.from}->${r.to}`).join(', ')}.\n` - ); - } else { - process.stdout.write( - 'check-system-context-census --fix: every declared count already matches the census -- nothing ' + - 'to rewrite there.\n' - ); - } - reportNoAnchorFix(); - } - const { problems, stats } = evaluate({ pageText, census, readFile, sweep }); for (const problem of problems) process.stderr.write(`::error::${problem}\n`); if (problems.length > 0) { process.stderr.write( `\ncheck-system-context-census: ${problems.length} problem(s) over ${stats.anchors} anchors ` + `and ${stats.sites} census sites.\n` + - 'Re-run the census with `node scripts/isystem-census.mjs --json`. A `[declared-count]` mismatch ' + - 'is mechanical -- `pnpm gen:system-context-census` (`--fix`) rewrites it from the census. ' + - '⛔ Everything else here is a human edit.\n' + + 'Re-run the census with `node scripts/isystem-census.mjs --json`. ⛔ Every finding here is a ' + + 'human edit -- nothing on the page is generated.\n' + `\nThe anchor grammar:\n ${ANCHOR_GRAMMAR}\n` ); return 1; @@ -1730,23 +1419,13 @@ function fixtureRead(relPath) { /** * A sweep result that found nothing. RESOLUTION is delegated to `sweepCorpus`, * which walks a real tree; the fixtures below drive POPULATION, CLASSIFICATION and - * COUNTS, and hand `evaluate` the shape a clean sweep returns. + * the dated decomposition, and hand `evaluate` the shape a clean sweep returns. * * ⭐ It is passed EXPLICITLY, never defaulted: `evaluate` refuses a missing sweep, * and a default would let that refusal be forgotten by every caller at once. */ const CLEAN_SWEEP = { findings: [], counts: { docs: 1, anchors: 2 } }; -/** A one-row stand-in for `DECLARED_COUNTS`, so the fixtures need one sentence. */ -const FIXTURE_COUNTS = [ - { - id: 'headline-sites', - pattern: /a single boolean read at \*\*(\d+)\s*\n?\s*distinct sites/, - value: (c) => c.sites.length, - why: 'fixture headline', - }, -]; - function fixturePage({ anchor = 'pkg/a.ts#handler', helper = 'pkg/a.ts#isSystemObjectName' } = {}) { return [ '---', @@ -1783,9 +1462,10 @@ function fixturePage({ anchor = 'pkg/a.ts#handler', helper = 'pkg/a.ts#isSystemO * inherits them, and then the throwaway corpus's `git init` creates nothing * while its `git add -A` writes THE REPOSITORY'S INDEX. * - * ⭐ That is not hypothetical and it is not a rare path: `check-regen-pending` - * runs this gate from `pre-commit`, which is precisely where an os-regen merge - * lap lands. Measured once, on this file's own branch, during exactly that lap: + * ⭐ That is not hypothetical: `check-regen-pending` ran this gate from + * `pre-commit` for as long as the page was `merge=os-regen` routed (until + * #22927), which is precisely where an os-regen merge lap lands, and any hook + * may run it again. Measured once, on this file's own branch, during that lap: * 8,190 paths staged as deleted and the fixture's own `pkg/a.ts` staged into the * real index, from a self-test whose every case still printed `ok`. * @@ -1925,7 +1605,7 @@ function insertRowAbove(pageText, n, cell) { /** * The page's UNENFORCED decomposition, as a fixture: the six rows plus the dated * marker. Every knob is a way the page could decay -- a number going stale, a row - * reworded away, the date dropped -- so the criterion can be driven from both sides. + * reworded away, the date dropped -- so the dated check can be driven from both sides. */ function fixtureUnenforcedTable({ linesTotal = 6, dropTestsRow = false, dated = true } = {}) { return [ @@ -1985,14 +1665,13 @@ function selfTest() { if (!ok) failures += 1; process.stdout.write(`${ok ? ' ok ' : ' FAIL'} ${name}${detail ? ` -- ${detail}` : ''}\n`); }; - const run = (page, census = FIXTURE_CENSUS, declaredCounts = [], unenforcedCounts = [], extra = {}) => + const run = (page, census = FIXTURE_CENSUS, unenforcedCounts = [], extra = {}) => evaluate({ pageText: page, census, readFile: fixtureRead, sweep: CLEAN_SWEEP, ledger: FIXTURE_LEDGER, - declaredCounts, unenforcedCounts, measuredAt: UNENFORCED_MEASURED_AT, // The row-reference ledger is about the REAL page's tables; these fixtures @@ -2067,19 +1746,19 @@ function selfTest() { // only thing asserted here is the delegation itself: the sweep's findings are // surfaced verbatim, and its ABSENCE is a refusal rather than a quiet pass. battery('⭐ RESOLUTION is delegated, and a sweep that did not run is a REFUSAL'); - const noSweep = run(fixturePage(), FIXTURE_CENSUS, [], [], { sweep: undefined }); + const noSweep = run(fixturePage(), FIXTURE_CENSUS, [], { sweep: undefined }); t( 'RESOLUTION: a missing sweep REFUSES -- "could not check" never reports as "checked and clean"', noSweep.problems.some((p) => p.startsWith('[no-sweep]')), noSweep.problems.join(' | ') ); - const brokenSweep = run(fixturePage(), FIXTURE_CENSUS, [], [], { sweep: { counts: {} } }); + const brokenSweep = run(fixturePage(), FIXTURE_CENSUS, [], { sweep: { counts: {} } }); t( 'RESOLUTION: a sweep object with no findings ARRAY is refused too, not read as zero findings', brokenSweep.problems.some((p) => p.startsWith('[no-sweep]')), brokenSweep.problems.join(' | ') ); - const sweptRed = run(fixturePage(), FIXTURE_CENSUS, [], [], { + const sweptRed = run(fixturePage(), FIXTURE_CENSUS, [], { sweep: { findings: [ { kind: 'unresolved-symbol', doc: PAGE, line: 5, raw: '`pkg/a.ts#handler`', detail: '`handler` has no declaration site' }, @@ -2105,7 +1784,6 @@ function selfTest() { sweep: CLEAN_SWEEP, ledger, unenforcedCounts: [], - declaredCounts: [], }); t( 'LEDGER: a symbol the file no longer declares is a finding', @@ -2140,127 +1818,32 @@ function selfTest() { ) ); - // ── counts ───────────────────────────────────────────────────────────────── - battery('counts'); - const countSentence = '\nit is a single boolean read at **1\ndistinct sites across 1 packages**.\n'; - const countPage = fixturePage() + countSentence; - const countsOk = run(countPage, FIXTURE_CENSUS, FIXTURE_COUNTS); - t( - 'COUNTS: a matching declared count is silent', - !countsOk.problems.some((p) => p.startsWith('[declared-count] `headline-sites`')) - ); - const countBad = - fixturePage() + '\nit is a single boolean read at **7\ndistinct sites across 1 packages**.\n'; - const countsRed = run(countBad, FIXTURE_CENSUS, FIXTURE_COUNTS); - t( - 'COUNTS: a wrong declared count is a finding', - countsRed.problems.some((p) => p.includes('`headline-sites` says 7, the census says 1')) - ); - t( - 'COUNTS: a pattern that matches nothing is a finding, not a silent skip', - run(fixturePage(), FIXTURE_CENSUS, FIXTURE_COUNTS).problems.some((p) => - p.startsWith('[count-pattern-unmatched]') - ) - ); - - const sectionPage = [ - '### 6. Reads that only carry the flag onward', - '', - '| # | Site |', - '|:--|:---|', - '| 62 | `pkg/a.ts#handler` |', - '| 63 | `pkg/a.ts#handler` |', - '', - '---', - '', - '| — carry the flag onward only (rows 62–63 above) | 2 |', - ].join('\n'); - t('COUNTS: the carry-onward split is read from section 6 itself', carryOnwardRowCount(sectionPage) === 2); - t('COUNTS: a renamed section 6 is underivable, not zero', carryOnwardRowCount('nothing here') === -1); - const underivable = evaluate({ - pageRowReferences: [], - pageText: fixturePage(), - census: FIXTURE_CENSUS, - readFile: fixtureRead, - sweep: CLEAN_SWEEP, - ledger: FIXTURE_LEDGER, - unenforcedCounts: [], - declaredCounts: [ - { id: 'x', pattern: /helper at `pkg\/a\.ts#(\w+)`/, value: () => carryOnwardRowCount('gone'), why: 'fixture' }, - ], - }); - t( - 'COUNTS: an underivable value is a finding, never compared as -1', - underivable.problems.some((p) => p.startsWith('[count-underivable]')) - ); - - // ── ⭐ CRITERION: enforced means CENSUS-DERIVED, pinned over the REAL lists ── - // - // The drift below is what an unrelated merge to `main` does to this repo: one - // test line that mentions the flag, one non-test source line carrying an - // `isSystem: true` key, one comment that names it. Nothing about the elevation - // population changes -- same sites, same packages, same files, same ledger. + // ── the dated decomposition: present and dated, never compared ───────────── // - // ⭐ These two cases run over `DECLARED_COUNTS` and `UNENFORCED_TEXT_COUNTS` - // THEMSELVES, not over a fixture stand-in. That is the point: move a text count - // back into the enforced list and the first case names it by id. - battery('⭐ CRITERION: enforced means CENSUS-DERIVED, pinned over the REAL lists'); + // The page's only numbers are the six whole-corpus TEXT counts. A stale value + // is not a finding -- they are never compared -- but a row reworded off the page, + // or the date that says how old they are, is. + battery('the dated decomposition: present and dated, never compared'); const textDrifted = { ...FIXTURE_CENSUS, roleCounts: { ...FIXTURE_CENSUS.roleCounts, key: FIXTURE_CENSUS.roleCounts.key + 1 }, text: { linesTotal: 6, linesInTests: 1, linesInSources: 5, identifierAppearances: 5, classified: 3, inCommentsAndStrings: 2 }, }; - const driftPage = fixturePage(); - const driftedEnforced = DECLARED_COUNTS.filter( - (d) => d.value(FIXTURE_CENSUS, driftPage) !== d.value(textDrifted, driftPage) - ); - t( - 'CRITERION: every ENFORCED count holds still under whole-corpus text drift', - driftedEnforced.length === 0, - driftedEnforced.map((d) => d.id).join(', ') - ); - const stuckUnenforced = UNENFORCED_TEXT_COUNTS.filter( - (d) => d.value(FIXTURE_CENSUS, driftPage) === d.value(textDrifted, driftPage) - ); + const staleText = run(fixturePage() + fixtureUnenforcedTable({ linesTotal: 999 }), textDrifted, UNENFORCED_TEXT_COUNTS); t( - 'CRITERION: all six UNENFORCED text counts DO move under that same drift', - UNENFORCED_TEXT_COUNTS.length === 6 && stuckUnenforced.length === 0, - `${UNENFORCED_TEXT_COUNTS.length} row(s); unmoved: ${stuckUnenforced.map((d) => d.id).join(', ')}` - ); - - // ── the same criterion, behaviourally, on one page ────────────────────────── - battery('the same criterion, behaviourally, on one page'); - const okPage = fixturePage() + countSentence; - const staleText = run( - okPage + fixtureUnenforcedTable({ linesTotal: 999 }), - textDrifted, - FIXTURE_COUNTS, - UNENFORCED_TEXT_COUNTS - ); - t( - 'CRITERION: a stale whole-corpus text count is NOT a finding', + 'DATED: a stale whole-corpus text count is NOT a finding', staleText.problems.length === 0, staleText.problems.join(' | ') ); - const rowGoneCase = run( - okPage + fixtureUnenforcedTable({ dropTestsRow: true }), - textDrifted, - FIXTURE_COUNTS, - UNENFORCED_TEXT_COUNTS - ); + const rowGoneCase = run(fixturePage() + fixtureUnenforcedTable({ dropTestsRow: true }), textDrifted, UNENFORCED_TEXT_COUNTS); t( - 'CRITERION: an unenforced row reworded off the page IS a finding', + 'DATED: a dated row reworded off the page IS a finding', rowGoneCase.problems.some((p) => p.startsWith('[unenforced-count-missing]') && p.includes('`table-lines-tests`')), rowGoneCase.problems.join(' | ') ); - const undated = run( - okPage + fixtureUnenforcedTable({ dated: false }), - textDrifted, - FIXTURE_COUNTS, - UNENFORCED_TEXT_COUNTS - ); + const undated = run(fixturePage() + fixtureUnenforcedTable({ dated: false }), textDrifted, UNENFORCED_TEXT_COUNTS); t( - 'CRITERION: unenforced numbers with no date are a finding, not a quiet pass', + 'DATED: numbers with no date are a finding, not a quiet pass', undated.problems.some((p) => p.startsWith('[unenforced-counts-undated]')), undated.problems.join(' | ') ); @@ -2268,27 +1851,20 @@ function selfTest() { // ── ⛔ and the half that must NOT have moved: the contract still reds ──────── battery('⛔ and the half that must NOT have moved: the contract still reds'); const rottedToo = run( - fixturePage({ anchor: 'pkg/a.ts#unrelated' }) + countSentence + fixtureUnenforcedTable({ linesTotal: 999 }), + fixturePage({ anchor: 'pkg/a.ts#unrelated' }) + fixtureUnenforcedTable({ linesTotal: 999 }), FIXTURE_CENSUS, - FIXTURE_COUNTS, UNENFORCED_TEXT_COUNTS ); t( - 'CRITERION: a re-keyed ANCHOR still reds on the very page whose text counts are stale', + 'CONTRACT: a re-keyed ANCHOR still reds on the very page whose text counts are stale', rottedToo.problems.some((p) => p.startsWith('[site-without-a-row]')) && rottedToo.problems.some((p) => p.startsWith('[anchor-is-not-a-read-site]')), rottedToo.problems.join(' | ') ); - const grewToo = run( - okPage + fixtureUnenforcedTable({ linesTotal: 999 }), - arrived, - FIXTURE_COUNTS, - UNENFORCED_TEXT_COUNTS - ); + const grewToo = run(fixturePage() + fixtureUnenforcedTable({ linesTotal: 999 }), arrived, UNENFORCED_TEXT_COUNTS); t( - 'CRITERION: a POPULATION change still reds on that same page', - grewToo.problems.some((p) => p.startsWith('[site-without-a-row] pkg/a.ts#unrelated')) && - grewToo.problems.some((p) => p.includes('`headline-sites` says 1, the census says 2')), + 'CONTRACT: a POPULATION change in a new symbol still reds on that same page', + grewToo.problems.some((p) => p.startsWith('[site-without-a-row] pkg/a.ts#unrelated')), grewToo.problems.join(' | ') ); @@ -2297,17 +1873,14 @@ function selfTest() { const noAnchors = run('---\ntitle: x\n---\n\nnothing here.\n'); t('ABSENCE: a page with no anchors refuses', noAnchors.problems.some((p) => p.startsWith('[no-anchors]'))); - // ── ⛔ --fix regenerates declared COUNTS only, never anchors, and says so ─── + // ── ⛔ --fix is retired: the gate writes nothing, and the flag refuses ───── // - // ⭐ Two failures this pins, in opposite directions. `gen:system-context-census` - // and `scripts/regen-artifacts.mjs` both invoke `--fix`; a flag that silently - // went back to being a no-op (the pre-#16919 shape) leaves both reading as a - // working regeneration path while every count red still needs a human. And a - // `--fix` that goes the OTHER way -- resurrecting the retired line-shift - // repair, or writing the page on a count it could not actually derive -- is - // exactly the "mechanical repair" this gate explicitly does not offer for - // anchors. Both directions are pinned on the SAME source read. - battery('⛔ --fix regenerates declared COUNTS only, never anchors, and says so'); + // ⭐ Two failures this pins, in opposite directions. A `--fix` that quietly ran + // the check would read as a regeneration that worked, so the flag must REFUSE -- + // run as a child, because the refusal is the dispatch's. And a gate that writes + // the page again -- the retired line-shift repair, or a count regenerator -- is a + // generated half coming back to a page #22927 made pure prose. + battery('⛔ --fix is retired: the gate writes nothing, and the flag refuses'); let ownSourceForFix = null; try { ownSourceForFix = readFileSync(join(ROOT, 'scripts/check-system-context-census.mjs'), 'utf8'); @@ -2321,76 +1894,30 @@ function selfTest() { 'the retired line-shift repair is back in this file' ); t( - '--fix: the count-regenerating function is exported, not inlined where nothing else can reach it', - /export function regenerateDeclaredCounts\(/.test(ownSourceForFix) - ); - t( - '--fix: the write is gated on there being something to write -- a no-op run must not touch the page', - /if \(rewrites\.length > 0\) \{/.test(ownSourceForFix) && - /writeFileSync\(\s*join\(ROOT, PAGE\)/.test(ownSourceForFix) - ); - t( - '--fix: the flag is still RECOGNISED and explains BOTH halves, so the wiring cannot go quiet', - /argv\.includes\('--fix'\)/.test(ownSourceForFix) && - /anchors have nothing to rewrite/.test(ownSourceForFix) && - /regenerated \$\{rewrites\.length\} declared count/.test(ownSourceForFix) + '--fix: nothing here writes the page, and no count regenerator survives -- the page is pure prose', + !/writeFileSync\(\s*join\(ROOT, PAGE\)/.test(ownSourceForFix) && + !/^export (?:const|function) (?:DECLARED_COUNTS|regenerateDeclaredCounts)\b/m.test(ownSourceForFix) ); } - - // ⭐ Behavioural, not textual: drive `regenerateDeclaredCounts` itself on the - // same fixtures the 'counts' battery already uses to pin the CHECK, so the - // WRITE can never define "correct" any differently than the comparison does. - const mismatched = fixturePage() + '\nit is a single boolean read at **7\ndistinct sites across 1 packages**.\n'; - const regenerated = regenerateDeclaredCounts({ pageText: mismatched, census: FIXTURE_CENSUS, declaredCounts: FIXTURE_COUNTS }); - t( - '--fix REGENERATES: a mismatched declared count is rewritten to the census value, and reported', - regenerated.errors.length === 0 && - regenerated.rewrites.length === 1 && - regenerated.rewrites[0].id === 'headline-sites' && - regenerated.rewrites[0].from === '7' && - regenerated.rewrites[0].to === '1', - JSON.stringify(regenerated.rewrites) - ); - t( - '--fix REGENERATES: the rewritten text carries the new digit and NOT the old one, surrounding prose untouched', - regenerated.text.includes('read at **1\ndistinct sites') && !regenerated.text.includes('**7\ndistinct sites'), - regenerated.text - ); + let fixRun = null; + try { + fixRun = spawnSync(process.execPath, [join(ROOT, 'scripts/check-system-context-census.mjs'), '--fix'], { + encoding: 'utf8', + env: gitFreeEnv(), + timeout: 30_000, + }); + } catch (err) { + fixRun = { status: null, stderr: String(err?.message ?? err) }; + } t( - '⭐ IDEMPOTENCE: a page that already agrees with the census comes back BYTE-IDENTICAL, zero rewrites', - (() => { - const already = fixturePage() + '\nit is a single boolean read at **1\ndistinct sites across 1 packages**.\n'; - const first = regenerateDeclaredCounts({ pageText: already, census: FIXTURE_CENSUS, declaredCounts: FIXTURE_COUNTS }); - const second = regenerateDeclaredCounts({ pageText: first.text, census: FIXTURE_CENSUS, declaredCounts: FIXTURE_COUNTS }); - return ( - first.rewrites.length === 0 && - first.text === already && - second.rewrites.length === 0 && - second.text === first.text - ); - })() + '--fix REFUSES: exit 2, before any census runs, so a caller cannot read it as a regeneration', + fixRun?.status === FIX_RETIRED_EXIT && !/check-system-context-census: OK/.test(fixRun?.stdout ?? ''), + `status ${fixRun?.status}` ); t( - '--fix REFUSES rather than writes a partial page: an unmatched or underivable count surfaces as an error', - (() => { - const unmatched = regenerateDeclaredCounts({ - pageText: fixturePage(), - census: FIXTURE_CENSUS, - declaredCounts: FIXTURE_COUNTS, - }); - const underivable = regenerateDeclaredCounts({ - pageText: fixturePage() + '\nhelper at `pkg/a.ts#isSystemObjectName`\n', - census: FIXTURE_CENSUS, - declaredCounts: [ - { id: 'x', pattern: /helper at `pkg\/a\.ts#(\w+)`/, value: () => carryOnwardRowCount('gone'), why: 'fixture' }, - ], - }); - return ( - unmatched.errors.some((e) => e.startsWith('[count-pattern-unmatched]')) && - unmatched.text === fixturePage() && - underivable.errors.some((e) => e.startsWith('[count-underivable]')) - ); - })() + '--fix REFUSES: and says why, naming where the live count lives now', + /RETIRED/.test(fixRun?.stderr ?? '') && /node scripts\/isystem-census\.mjs/.test(fixRun?.stderr ?? ''), + (fixRun?.stderr ?? '').replace(/\s+/g, ' ').slice(0, 160) ); // ── ⭐ THE RULED RED-FIRST PAIR, on a real tree ───────────────────────────── @@ -2487,8 +2014,9 @@ function selfTest() { // ⛔ Written as a PASSING case on purpose. The gap is real and declared -- on the // page and in this file's header -- and a self-test that quietly omitted it // would leave the next reader to find it as a defect and "fix" it by weakening - // something else. Two reads inside ONE symbol, one of them deleted: the symbol - // set does not move, and this gate does not red. + // something else. Two reads inside ONE symbol, one of them added or deleted: the + // symbol set does not move, and this gate does not red. (Until #22927 the page's + // site total moved on that edit -- see the header for what that tripwire was.) battery('⭐ the precision this trades away, pinned so nobody rediscovers it as a bug'); const twoInOne = { ...FIXTURE_CENSUS, @@ -2500,8 +2028,8 @@ function selfTest() { const bothPresent = run(fixturePage(), twoInOne); const oneDeleted = run(fixturePage(), FIXTURE_CENSUS); t( - '⛔ THE DECLARED GAP: deleting one of two reads inside one symbol does NOT red -- stated here ' + - 'rather than discovered later', + '⛔ THE DECLARED GAP: adding or deleting one of two reads inside one symbol does NOT red -- ' + + 'stated here rather than discovered later', bothPresent.problems.length === 0 && oneDeleted.problems.length === 0, `${bothPresent.problems.join(' | ')} // ${oneDeleted.problems.join(' | ')}` ); @@ -2979,5 +2507,6 @@ if (isEntrypoint(import.meta.url)) { } process.exit(selfTestCode); } - process.exit(run({ fix: argv.includes('--fix') })); + if (argv.includes('--fix')) process.exit(refuseRetiredFix()); + process.exit(run()); } diff --git a/scripts/pm/os-regen-merge.sh b/scripts/pm/os-regen-merge.sh index 4680e21fe39..e58b172a519 100644 --- a/scripts/pm/os-regen-merge.sh +++ b/scripts/pm/os-regen-merge.sh @@ -471,7 +471,9 @@ read_regen_patterns() { # .gitattributes and to that field both ways, and pins a rerun fixture on every mixed row. # whole its generator renders the ENTIRE file — nothing on disk survives into it; # mixed a generated half plus hand-written rows no regeneration restores. -# Measured at the generators (2026-10-11): `gen:system-context-census` alone edits in place. +# Measured at the generators (2026-10-11): `gen:system-context-census` alone edited in place, +# and it retired with that page's route (#22927) — no pattern is `mixed` today, so the +# self-test drives the mixed limb on a SYNTHETIC pattern until a real one arrives. OS_REGEN_CLASSES=' packages/spec/liveness/state-counts/** whole packages/spec/authorable-surface/** whole @@ -485,7 +487,6 @@ packages/spec/declaration-map/** whole packages/spec/api-surface-signatures.json whole docs/audits/2026-07-unknown-key-strictness-ledger.counts/** whole content/docs/references/** whole -content/docs/permissions/system-context.mdx mixed skills/*/references/_index.md whole skills/objectstack-ui/references/react-blocks.md whole scripts/platform-object-tenancy-census.json whole @@ -2423,12 +2424,23 @@ mode_self_test() { st_whole="$(printf '%s\n' "$OS_REGEN_CLASSES" | awk '$2 == "whole" { print $1; exit }')" { cat "$st_repo/.gitattributes"; printf 'docs/new-generated/** merge=os-regen\n'; } > "$tmp/attrs-new" awk -v p="$st_whole" '$1 != p' "$st_repo/.gitattributes" > "$tmp/attrs-stale" + # The third leg adds one SYNTHETIC mixed route to the table and to .gitattributes, so a + # driver without mixed rows has at least one row to mismatch even while no real pattern + # is mixed — otherwise '' would equal '' and the control would fire on nothing. + st_mixed_fx='docs/fixture/mixed-census.mdx' + { cat "$st_repo/.gitattributes"; printf '%s merge=os-regen\n' "$st_mixed_fx"; } > "$tmp/attrs-mixed" + st_classes_mixed="$OS_REGEN_CLASSES$st_mixed_fx mixed +" st_case 'control: a new route reds unclassified, a dropped one stale, a driver without mixed rows mismatched' \ - "$(os_regen_class_problems "$tmp/attrs-new" "$st_drv" | tr '\n' ';')|$(os_regen_class_problems "$tmp/attrs-stale" "$st_drv" | tr '\n' ';')|$(os_regen_class_problems "$st_repo/.gitattributes" '' | tr '\n' ';')" \ - "unclassified docs/new-generated/**;|stale $st_whole;|$(printf '%s\n' "$OS_REGEN_CLASSES" | awk '$2 == "mixed" { print "mixed-mismatch " $1 }' | sort | tr '\n' ';')" + "$(os_regen_class_problems "$tmp/attrs-new" "$st_drv" | tr '\n' ';')|$(os_regen_class_problems "$tmp/attrs-stale" "$st_drv" | tr '\n' ';')|$(OS_REGEN_CLASSES="$st_classes_mixed" os_regen_class_problems "$tmp/attrs-mixed" '' | tr '\n' ';')" \ + "unclassified docs/new-generated/**;|stale $st_whole;|$(printf '%s\n' "$st_classes_mixed" | awk '$2 == "mixed" { print "mixed-mismatch " $1 }' | sort | tr '\n' ';')" # --- 13. THE CARD, once per MIXED row: a branch-edited hand row main never touched, a # conflict elsewhere, the merge committed, the rerun — and main advanced meanwhile. + # With no MIXED row in the table (none since #22927), the synthetic pattern of the + # control above stands in, so the mixed limb is never left unexercised. + st_mixed_list="$(printf '%s\n' "$OS_REGEN_CLASSES" | awk '$2 == "mixed" { print $1 }')" + [ -n "$st_mixed_list" ] || st_mixed_list="$st_mixed_fx" st_mixed_runs=0 while IFS= read -r st_mx <&3; do st_mixed_runs=$((st_mixed_runs + 1)) @@ -2442,14 +2454,14 @@ mode_self_test() { st_case "$st_mx: main advanced past the record, and the branch-only path keeps the branch bytes" \ "$(printf '%s' "$out" | grep -c 'origin/main has ADVANCED' || true)/$(git show HEAD:gen/branchonly.txt)" '1/branchonly v1-BRANCH' cd "$here" - done 3< <(printf '%s\n' "$OS_REGEN_CLASSES" | awk '$2 == "mixed" { print $1 }') - st_case 'the table names at least one MIXED pattern, and every one ran its fixture' \ + done 3< <(printf '%s\n' "$st_mixed_list") + st_case 'every MIXED pattern ran its fixture, and at least one ran (the synthetic one when the table names none)' \ "$([ "$st_mixed_runs" -gt 0 ] && echo "$st_mixed_runs")" \ - "$(printf '%s\n' "$OS_REGEN_CLASSES" | awk '$2 == "mixed"' | grep -c . || true)" + "$(printf '%s\n' "$st_mixed_list" | grep -c . || true)" # --- 13a. THE DISCRIMINATING MUTATIONS for 13: each fix's ONE line put back, and exactly # its own half of the card comes back. Same perl/\Q..\E replacement 6b–11b use. - st_mx="$(printf '%s\n' "$OS_REGEN_CLASSES" | awk '$2 == "mixed" { print $1; exit }')" + st_mx="$(printf '%s\n' "$st_mixed_list" | awk 'NF { print $1; exit }')" for st_mut in \ 'mixed| case "$mixed_edited" in| case "" in|0/1/0/branchonly v1-BRANCH' \ 'ref|"$merge_base" "$main_side" -- "${regen_paths[@]}")$NL"|"$merge_base" origin/main -- "${regen_paths[@]}")$NL"|0/0/1/branchonly v0'; do diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 711616a0525..1dcf2599b90 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -53,7 +53,9 @@ export const ROOT_OWNER = '@objectstack/spec-monorepo'; * * `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 + * merge (`content/docs/permissions/system-context.mdx` was the measured case, routed + * from #13646 until #22927 retired the census totals that were its last generated + * half; no row carries `mixed` today) — 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 @@ -230,117 +232,16 @@ export const REGEN_ARTIFACTS = Object.freeze([ gen: 'gen:liveness-counts', check: 'check:liveness', }, - // #13646. The elevation census page, and the first row here owned by ROOT tooling - // rather than by `packages/spec` (the owner field #13585 added exists for exactly - // this). It was routed as a generated `file:line` anchor table, and since #15921 - // it carries no line anchors at all: the argument below is kept as the record of - // why the row exists, in the past tense, and "Today" at the end of this block is - // the current state. - // - // ⚠️ The row is the FILE, not `content/docs/permissions/**`, and the difference - // is safety rather than tidiness. Its routed sibling `content/docs/references/**` - // is a whole generated tree; `content/docs/permissions/` is 22 hand-written prose - // pages with ONE generated page among them, so the directory glob would hand 21 - // prose files to a driver that resolves to OURS — laundering away a sibling's - // prose edit, which is the exact trade the migration registry was kept out of - // this table for while it was committed. The glob is recorded in NOT_DRIVER_MANAGED below. - // - // Why it was routed here (#13646, while the page carried `file:line` anchors): two - // PRs that each ran `--fix` against their own tree wrote correct-for-themselves - // line numbers into the same rows, and the merged tree's correct values equalled - // NEITHER side — measured on #13625's merge, where the five conflicted anchors - // resolved to `4408/5771/6019/6382/6575` against branch `4407/5770/…` and main - // `4284/5647/…`. A text merge could not reach that answer from either input, so it - // was a deferral-and-regenerate shape. - // - // ⚠️ #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 was TRUE and it covered HALF of - // what the driver discards. The gate re-derived 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 did not coincide, and the gap was not theoretical - // (#14064's measurements, taken while the page still carried line anchors): - // - // - 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 was not a rare path. main rewrote this page about every 75 minutes, so any - // PR touching it and older than an hour met the driver by construction. - // - // ⭐ #14064 kept the routing, and its argument was right about the page it - // measured: the merged tree's correct anchors were on NEITHER side (measured on - // #13625 — five conflicted anchors resolved to 4408/5771/6019/6382/6575 against - // branch 4407/5770/… and main 4284/5647/…), so no text merge and no hand merge - // could reach them. Moving this row to NOT_DRIVER_MANAGED would have bought the - // prose back by handing a page that conflicted hourly to a human who could not - // resolve it correctly. The census over every routed path said that trade was - // worse than it looked: of the last 25 main commits to this page when that was - // measured, 24 changed nothing but anchor line numbers — the case the driver - // handled correctly and cheaply — and exactly 1 touched prose. - // - // ⇒ So the row kept its routing and declared WHEN the deferral is lossless. - // `mixed` names the equivalence: two revisions equal after - // `blankAnchorLineNumbers` differed only in the anchor numbers `--fix` re-derived - // then, and dropping either lost nothing. `git-merge-regen.mjs` refuses to defer - // silently when they are not equal, so the 1-in-25 prose case became a text merge - // or a loud conflict instead of a silent deletion, and the 24-in-25 anchor case - // kept #13646's win. This is still 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. - // - // ⭐ Today (since #15921): every citation on the page is a `path#symbol` anchor - // (`scripts/symbol-anchors.mjs`), which does not move when its file grows, and the - // census gate reports a surviving line number as a finding. So - // `blankAnchorLineNumbers` finds nothing to blank, and the `'line-anchors'` - // comparator is the IDENTITY on this page — measured on #16612, - // `extractLineAnchors(page).length === 0` and - // `blankAnchorLineNumbers(page) === page`. - // A deferral is proven lossless only when THEIRS equals the ancestor or OURS byte - // for byte, so every real edit — prose, an anchor, a declared count — takes the - // text-merge branch, or conflicts loudly. That is by design, not a degraded mode: - // it is the safe half of #14064's trade, and what it gives up is the 24-in-25 - // renumbering case, which can no longer occur because there are no numbers left to - // renumber. A clean text merge is still recorded as owing - // `gen:system-context-census`, which `pre-commit` collects. - // - // ⛔ Keep `mixed: 'line-anchors'` anyway. The field is not only the comparator's - // name; it is what routes this row through the lossless check at all. Without it - // the driver takes the unconditional deferral every wholly-generated row takes — - // keep OURS, drop THEIRS — and that is the silent deletion #14064 closed. Measured - // on #16612 in a throwaway repo with this driver, ours and theirs making - // non-overlapping edits: field present ⇒ text-merged, the incoming paragraph kept; - // field deleted ⇒ `git merge` exit 0, no markers, the incoming paragraph gone. It - // is also the row `endToEndMixed()` in `git-merge-regen.mjs --self-test` exercises, - // and that self-test reports "nothing to prove", and passes, once no row carries - // `mixed`. - // - // No `readsDist`/`readsSchemaTree`: the census is an AST walk over `src/`, so a - // merged tree is the whole prerequisite. `gen` still cannot launder a POPULATION - // change into a row nobody wrote: a site that arrived or vanished needs a human - // to add or drop its table row, and `--fix` does not touch table rows. #16919 - // narrowed what stays a REFUSAL, though — `--fix` now regenerates every - // `DECLARED_COUNTS` sentence (the headline, the decomposition table, the ruling - // quote, …) straight from the census, the same computation the COUNTS check - // already runs, applied as a write instead of a comparison. So a merge that - // silently produced a wrong SUM (the shape #16919 was filed over — two branches - // each correctly bump the same sentence, text-merge clean, and the merged total - // is neither side's number) is repaired by running the generator on the merged - // tree, not by a human re-deriving and re-typing the number by hand. - { - path: 'content/docs/permissions/system-context.mdx', - gen: 'gen:system-context-census', - check: 'check:system-context-census', - owner: ROOT_OWNER, - mixed: 'line-anchors', - }, + // #13646 → #22927. `content/docs/permissions/system-context.mdx`, the elevation + // census page, was the row here: the first one owned by ROOT tooling and the one + // row with `mixed: 'line-anchors'` (#14064 measured a whole-side deferral dropping + // its prose). Its generated half was its `file:line` anchors until #15921 made + // them `path#symbol`, then its census totals until #22927's ruling took those off + // the page with `gen:system-context-census`. Nothing on it is generated, so it is + // no artifact and has no row; it text-merges like the prose around it, and + // `check:system-context-census` still re-derives the census on every PR. The + // measurements this row carried are in `git log -p scripts/regen-artifacts.mjs`. + // #13335 / #13731. The per-skill reference index — one row per `packages/spec` // source module, rendered whole by `gen:skill-refs`. Nine tracked files today, // matched as a SEGMENT glob so a tenth skill arrives routed instead of arriving @@ -356,7 +257,7 @@ export const REGEN_ARTIFACTS = Object.freeze([ // // Both sides are correct-for-themselves projections of their own tree, and the // merged tree's index equals NEITHER — the same "no text merge can reach the - // answer" property that put the elevation census page above here. The correct + // answer" property that once put the elevation census page in this table. The correct // resolution #13335 records by hand (take either side, commit the merge, // regenerate, let `check:skill-refs` prove it) is exactly what this row plus // `os-regen-merge.sh` step 4 do mechanically. @@ -517,14 +418,13 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ path: 'content/docs/permissions/**', why: 'the DIRECTORY is not what #13646 routed, and recording that is the point of this ledger. ' - + '22 of its 23 pages are hand-written permissions prose; exactly one — `system-context.mdx`, ' - + 'declared above — is routed, as a MIXED row (a generated `file:line` anchor table when it was ' - + 'routed; today its anchors are `path#symbol` ones and only its declared counts are ' - + 'generated). Routing the tree the way its sibling ' + + 'Its pages are hand-written permissions prose. One — `system-context.mdx` — was routed as a ' + + 'MIXED file from #13646 until #22927 retired the census totals that were its last generated ' + + 'half; it is plain prose now and is routed nowhere. Routing the tree the way its sibling ' + '`content/docs/references/**` is routed reads as symmetry and is not: that sibling is ' - + 'generated whole, this one would defer 21 prose files to OURS and lose the other side\'s ' - + 'edits silently. Route the generated FILE; leave the neighbours to text-merge, which is ' - + 'correct for prose and always was.', + + 'generated whole, this one would defer every prose page to OURS and lose the other side\'s ' + + 'edits silently. Should a generated page arrive here, route that FILE and leave its ' + + 'neighbours to text-merge, which is correct for prose and always was.', }, // ── #13731: the remaining generators, one recorded disposition each ────────── {