diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 71e9d35dad..a14431ef2e 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -2,8 +2,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * published-skills TOKEN ratchet (#10473) — shrink-only ceilings on every - * SKILL.md in the catalog that ships to customer projects. + * published-skills TOKEN ratchet (#10473, extended #12392) — shrink-only + * ceilings on every HAND-AUTHORED file in the catalog that ships to customer + * projects, not merely on each skill's SKILL.md. * * node scripts/check-skills-token-ratchet.mjs # the gate * node scripts/check-skills-token-ratchet.mjs --self-test # verify the checker @@ -90,20 +91,87 @@ * * ## Enumeration, never a hand list * - * The catalog is read off the filesystem (every `skills//SKILL.md`), and a - * discovered file with no ceiling is RED. A hand-maintained list would let the - * twelfth published skill land unpriced, which is precisely the hole the ruling - * is about — the bundle grows by a whole file, and every existing ceiling stays - * green. Missing file or empty read is RED, never a pass (#4690: a gate that - * cannot find its input must fail, not skip). + * The catalog is read off the filesystem — every file under every published + * skill directory, walked recursively — and a discovered AUTHORED file with no + * ceiling is RED. A hand-maintained list would let the twelfth published skill + * land unpriced, which is precisely the hole the ruling is about — the bundle + * grows by a whole file, and every existing ceiling stays green. Missing file + * or empty read is RED, never a pass (#4690: a gate that cannot find its input + * must fail, not skip). + * + * ## What is priced, and what is deliberately NOT — the boundary (#12392) + * + * ⚠️ Read this before concluding the gate prices `skills/**` as a category. It + * does not, and the shape of the exception is the whole point of this section. + * Until #12392 this gate priced ONLY `skills//SKILL.md` — 11 files — while + * the bundle that ships is 50. The other 37% (`rules/**`, `evals/**`, the + * hand-authored `references/*.md`) carried no ceiling at all, so an author could + * add thousands of tokens of `rules/*.md` and truthfully report a `+0` package + * delta against the only number this gate produced. The population below is the + * repair; this paragraph exists so the boundary is legible WITHOUT diffing the + * population against the filesystem, which is what #12392 was filed about. + * + * Three populations, and each one's reason: + * + * 1. PRICED — every hand-authored file inside a published skill directory. + * A published skill directory is a child of `skills/` carrying a SKILL.md; + * everything under it ships into the customer project together, so + * everything under it is priced together. `SKILL.md`, `rules/**`, + * `evals/**` and hand-authored `references/**` are all this. + * + * 2. EXCLUDED — GENERATOR-OWNED outputs. `skills//references/_index.md` + * is written by `gen:skill-refs` and `references/react-blocks.md` + + * `contracts/react-blocks.contract.json` by `gen:react-blocks`. A ceiling + * on a generated file reds on a legitimate REGENERATION — a spec author + * moves an indexed headline, the generator faithfully rewrites the index, + * and this gate stops them. That is the wrong actor: the generated bytes + * are a function of the schema source, so the author who could pay the + * ratchet's price by deleting text is not the author the red would land on. + * Their cost is still MEASURED and printed below — excluded from + * RATCHETING is not excluded from the bundle total. + * + * 3. OUTSIDE — `skills/README.md`. Not inside any published skill directory: + * it is the catalog's own front page (and itself carries a generated index + * block from `build-skill-docs.ts`), not content an agent loads with a + * skill. The exclusion is structural, not a carve-out. + * + * ## The one definition of "generator-owned" — reused, never re-authored + * + * Membership in population 2 is decided by `generatedExceptionFor()` from + * `scripts/pm/check-governed-merges.mjs` — the register the maintainer ruled + * into existence for exactly this question (#11705, 2026-08-25, on the + * governed-merge fork): "The exemption is **enumerated from the generator**, + * never a hand-copied path list … Extend that registry; ⛔ do not author a + * second mechanism." This gate obeys that literally: it imports the register's + * membership test rather than restating which paths are generated. Two + * definitions of "generator-owned" that can drift is the failure this avoids — + * if a generator's output set changes, it changes in ONE place and both the + * governed-merge fork and this ratchet follow it. + * + * ⚠️ Known limit of the reuse, stated rather than hidden. The register's rows + * answer the QUESTION cheaply (a narrowing regexp) and prove the ANSWER + * expensively (recompute the generator, compare bytes). This gate runs in the + * pre-build lint group with no toolchain, so it can only take the cheap half. + * The residual risk is one-directional and small: a hand-authored file whose + * path matches a generated spelling — an `_index.md` under a skill the + * generator does not write — would be excluded rather than priced. It is closed + * by PINNING the excluded set in `--self-test`: the excluded paths are asserted + * by name, so a new one cannot appear without reddening this gate's own + * self-test and getting a human's eyes. The pin is not a second definition; it + * is a tripwire on the first one. */ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import process from 'node:process'; import { isEntrypoint } from './invoked-as.mjs'; +// THE membership test for "generator-owned", imported rather than restated — +// see the boundary section in the header. #11705's ruling forbids a second +// mechanism, so this gate consults the register the governed-merge fork owns. +import { generatedExceptionFor } from './pm/check-governed-merges.mjs'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const REPO_ROOT = resolve(HERE, '..'); @@ -154,6 +222,16 @@ export const CEILING_BASIS = { * whenever one is taken, not merely by the size of the day's deletion. */ strippedInternalIds: '3f571a6d2', + /** + * #12392's extension basis. Every row added below the `SKILL.md` block was + * measured on this sha — a fresh `origin/main` tree — and initialised AT its + * measurement, so each of the 27 newly-priced files starts with exactly zero + * headroom. The `SKILL.md` rows were NOT re-measured by that change and keep + * `strippedInternalIds` as their origin; this basis explains only the new + * rows, which is why it is a separate field rather than a rewrite of the one + * above. + */ + bundleExtension: 'c026b0d2d', }; /** @@ -186,6 +264,61 @@ export const CEILINGS = new Map([ ['skills/objectstack-query/SKILL.md', 5552], // -17 (was 5569) ['skills/objectstack-ui/SKILL.md', 25125], // -29 (was 25154) ['skills/objectstack-upgrade/SKILL.md', 8333], // -2 (was 8335) + + // ── the #12392 extension: the rest of the AUTHORED bundle ──────────────── + // Every row below is an INITIAL measurement, taken on + // CEILING_BASIS.bundleExtension, of a file that shipped to every customer + // project while carrying no ceiling at all. They are initialised AT the + // measurement, which is what makes this a pure ratchet installation and not + // a budget grant: every one of these files starts with exactly zero + // headroom, so the next token added to any of them is paid for by deleting + // one from the same file — the same terms `SKILL.md` has had since #10473. + // ⚠️ Nothing here is an invitation to grow to a number; see the shrink-only + // section in the header for what the other direction costs. + + // objectstack-data — the heaviest unpriced surface by a wide margin. + // `references/data-hooks.md` alone was larger than nine of the eleven + // SKILL.md ceilings, which is the single fact that made #12392 a defect + // rather than an observation. + ['skills/objectstack-data/evals/README.md', 143], + ['skills/objectstack-data/references/data-hooks.md', 12611], + ['skills/objectstack-data/rules/datasources.md', 911], + ['skills/objectstack-data/rules/field-types.md', 3584], + ['skills/objectstack-data/rules/hooks.md', 2195], + ['skills/objectstack-data/rules/indexing.md', 3241], + ['skills/objectstack-data/rules/lifecycle.md', 1590], + ['skills/objectstack-data/rules/naming.md', 773], + ['skills/objectstack-data/rules/relationships.md', 3778], + ['skills/objectstack-data/rules/validation.md', 3024], + + // objectstack-platform + ['skills/objectstack-platform/evals/README.md', 514], + ['skills/objectstack-platform/references/plugin-hooks.md', 2628], + ['skills/objectstack-platform/rules/bootstrap-patterns.md', 1093], + ['skills/objectstack-platform/rules/plugin-hooks-events.md', 985], + ['skills/objectstack-platform/rules/plugin-lifecycle.md', 2408], + ['skills/objectstack-platform/rules/service-registry.md', 2331], + + // objectstack-query + ['skills/objectstack-query/evals/README.md', 567], + ['skills/objectstack-query/rules/aggregation.md', 2357], + ['skills/objectstack-query/rules/filters.md', 2149], + ['skills/objectstack-query/rules/pagination.md', 1382], + + // objectstack-automation + ['skills/objectstack-automation/evals/README.md', 414], + ['skills/objectstack-automation/evals/approvals/test-revise-loop.md', 1329], + + // objectstack-ui — the two authored eval files; its `references/react-blocks.md` + // and `contracts/react-blocks.contract.json` are generator-owned and carry no + // row here on purpose (see the boundary section). + ['skills/objectstack-ui/evals/README.md', 289], + ['skills/objectstack-ui/evals/analytics-inline-vs-dataset.json', 1102], + + // the remaining skills' eval notes + ['skills/objectstack-ai/evals/README.md', 315], + ['skills/objectstack-api/evals/README.md', 546], + ['skills/objectstack-i18n/evals/README.md', 411], ]); /** @@ -200,43 +333,98 @@ export function countTokens(text) { } /** - * Every published SKILL.md on disk — read off the filesystem, never a hand list. + * Every file that ships inside a published skill — read off the filesystem, + * never a hand list, walked RECURSIVELY (#12392). * * A directory under `skills/` carrying no SKILL.md is not a published skill and - * is skipped; `skills/README.md` is not a directory and never appears. + * is skipped whole; `skills/README.md` is not inside a skill directory and never + * appears (population 3 in the header). The walk is what makes a twelfth FILE + * inside an existing skill as impossible to land unpriced as a twelfth skill. * * @param {string} [root] * @returns {string[]} repo-relative paths, sorted */ -export function discoverSkillFiles(root = REPO_ROOT) { - const dir = root === REPO_ROOT ? SKILLS_DIR : join(root, 'skills'); - return readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => `skills/${entry.name}/SKILL.md`) - .filter((rel) => existsSync(join(root, rel))) - .sort(); +export function discoverBundleFiles(root = REPO_ROOT) { + const skillsDir = root === REPO_ROOT ? SKILLS_DIR : join(root, 'skills'); + const found = []; + + /** @param {string} rel */ + const walk = (rel) => { + for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) { + const child = `${rel}/${entry.name}`; + if (entry.isDirectory()) walk(child); + else if (entry.isFile()) found.push(child); + } + }; + + for (const entry of readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const rel = `skills/${entry.name}`; + if (!existsSync(join(root, rel, 'SKILL.md'))) continue; + walk(rel); + } + return found.sort(); +} + +/** + * Is this bundle path a generator's output? The register's row, or null. + * + * A thin, DELIBERATE pass-through: the reason it exists is to be the single + * place this gate touches the boundary, so a reader (and the self-test) can see + * that the answer comes from `check-governed-merges.mjs` and from nowhere else. + * ⛔ Never replace this with a local regexp — see the header's "one definition". + * + * @param {string} rel + * @returns {{id: string, generator: string} | null} + */ +export function generatorOwning(rel) { + return generatedExceptionFor(rel); } /** * @param {string} rel * @param {number} tokens * @param {number | undefined} ceiling + * @param {{id: string, generator: string} | null} [generated] the register row owning this path * @returns {{ok: boolean, msg: string}} */ -export function verdict(rel, tokens, ceiling) { +export function verdict(rel, tokens, ceiling, generated = null) { if (tokens === 0) { return { ok: false, msg: `${rel} read as empty — refusing to treat a missing/empty input as a pass (#4690).` }; } + if (generated) { + // A ceiling on a generator's output is a contradiction, not a stricter + // setting: it reds on the next regeneration and the author it stops cannot + // pay by deleting text, because they did not write the bytes. Caught here + // rather than left to be discovered by the regeneration that trips it. + if (ceiling !== undefined) { + return { + ok: false, + msg: + `${rel} carries a CEILINGS row but is a GENERATOR-OWNED output (\`${generated.generator}\`, register row ` + + `\`${generated.id}\`). Generated files are measured, never ratcheted: a ceiling here reds on a legitimate ` + + 'regeneration, and the author that red lands on did not write the bytes and cannot pay by deleting them. ' + + 'Remove the row — the file stays in the printed bundle total either way.', + }; + } + return { + ok: true, + msg: `${rel} is ${tokens} tokens — generator-owned (\`${generated.generator}\`), measured but not ratcheted.`, + }; + } if (ceiling === undefined) { return { ok: false, msg: - `${rel} is a published SKILL.md carrying no ceiling, measured now at ${tokens} tokens. ` - + 'Every file in the catalog is priced, because an unpriced file is unpriced growth — the ' - + 'bundle grows by a whole file while every existing ceiling stays green. ' - + `${RATCHET_AUTHORITY_MARKER}: adding a CEILINGS row prices a new skill into the bundle ` + `${rel} is a hand-authored published bundle file carrying no ceiling, measured now at ${tokens} tokens. ` + + 'Every authored file in the catalog is priced, because an unpriced file is unpriced growth — the ' + + 'bundle grows by a whole file while every existing ceiling stays green. That hole is what #12392 ' + + 'measured: a file inside an existing skill used to land unpriced even though a whole new skill could not. ' + + 'If this path is in fact a generator output, it belongs in the register in ' + + '`scripts/pm/check-governed-merges.mjs` (#11705) — never in a second list here. ' + + `${RATCHET_AUTHORITY_MARKER}: adding a CEILINGS row prices new text into the bundle ` + 'that ships to every customer project, which is the maintainer ruling this ratchet ' - + 'implements — not a step an author takes while landing the skill.', + + 'implements — not a step an author takes while landing the file.', }; } if (tokens > ceiling) { @@ -266,25 +454,38 @@ export function verdict(rel, tokens, ceiling) { */ export function reportLines(rows) { const out = [`── published skills bundle — price tag (convention: ${TOKEN_CONVENTION})`]; - let totalTokens = 0; - let totalCeiling = 0; - for (const { rel, tokens, ceiling } of rows) { - totalTokens += tokens; - totalCeiling += ceiling ?? 0; + let pricedTokens = 0; + let pricedCeiling = 0; + let generatedTokens = 0; + for (const { rel, tokens, ceiling, generated } of rows) { + if (generated) { + generatedTokens += tokens; + out.push(` ${rel.padEnd(58)} ${String(tokens).padStart(6)} / — (generated)`); + continue; + } + pricedTokens += tokens; + pricedCeiling += ceiling ?? 0; const delta = ceiling === undefined ? 'unpriced' : `${tokens - ceiling >= 0 ? '+' : ''}${tokens - ceiling}`; const shown = ceiling === undefined ? 'none' : String(ceiling); - out.push(` ${rel.padEnd(40)} ${String(tokens).padStart(6)} / ${shown.padStart(6)} (${delta})`); + out.push(` ${rel.padEnd(58)} ${String(tokens).padStart(6)} / ${shown.padStart(6)} (${delta})`); } - const net = totalTokens - totalCeiling; + const net = pricedTokens - pricedCeiling; + // Three lines, because #12392's complaint was that this gate could not + // produce a whole-tree `skills/**` delta: its reading was byte-identical + // before and after a PR that changed three published files. The generated + // subtotal is what makes the last line a real bundle number rather than the + // ratcheted subset wearing that name. out.push( - ` ${'bundle total'.padEnd(40)} ${String(totalTokens).padStart(6)} / ${String(totalCeiling).padStart(6)}` + ` ${'ratcheted (authored)'.padEnd(58)} ${String(pricedTokens).padStart(6)} / ${String(pricedCeiling).padStart(6)}` + ` (${net >= 0 ? '+' : ''}${net})`, ); + out.push(` ${'generator-owned (measured, not ratcheted)'.padEnd(58)} ${String(generatedTokens).padStart(6)} / —`); + out.push(` ${'bundle total (whole shipped tree)'.padEnd(58)} ${String(pricedTokens + generatedTokens).padStart(6)}`); return out; } function run() { - const covered = [...new Set([...discoverSkillFiles(), ...CEILINGS.keys()])].sort(); + const covered = [...new Set([...discoverBundleFiles(), ...CEILINGS.keys()])].sort(); const rows = []; let failed = 0; @@ -299,8 +500,9 @@ function run() { } const tokens = countTokens(text); const ceiling = CEILINGS.get(rel); - rows.push({ rel, tokens, ceiling }); - const v = verdict(rel, tokens, ceiling); + const generated = generatorOwning(rel); + rows.push({ rel, tokens, ceiling, generated }); + const v = verdict(rel, tokens, ceiling, generated); if (!v.ok) { failed++; console.error(`✗ check-skills-token-ratchet: ${v.msg}`); @@ -309,21 +511,70 @@ function run() { console.log(`✓ check-skills-token-ratchet: ${v.msg}`); } + const ratcheted = rows.filter((r) => !r.generated).length; + console.log(''); for (const line of reportLines(rows)) console.log(line); if (failed) { - console.error(`\n✗ check-skills-token-ratchet: ${failed} of ${covered.length} published SKILL.md over budget.`); + console.error(`\n✗ check-skills-token-ratchet: ${failed} of ${covered.length} published bundle file(s) failed their check.`); process.exit(1); } - console.log(`\n✓ check-skills-token-ratchet: ${covered.length} published SKILL.md within their ceilings.`); + console.log( + `\n✓ check-skills-token-ratchet: ${ratcheted} authored bundle file(s) within their ceilings; ` + + `${covered.length - ratcheted} generator-owned file(s) measured, not ratcheted.`, + ); +} + +/** + * A throwaway skills tree, so the WALK itself is pinned against something whose + * shape this test controls. Without it the enumeration cases can only ask the + * real tree, and a walk that quietly stopped recursing would still satisfy + * every one of them by returning the SKILL.md files it did find. + * + * @returns {{root: string, cleanup: () => void}} + */ +function fixtureTree() { + const root = mkdtempSync(join(tmpdir(), 'skills-ratchet-')); + const write = (rel, text) => { + mkdirSync(dirname(join(root, rel)), { recursive: true }); + writeFileSync(join(root, rel), text); + }; + write('skills/README.md', '# catalog front page\n'); + write('skills/demo/SKILL.md', '# demo\n'); + write('skills/demo/rules/nested.md', '# nested rule\n'); + write('skills/demo/evals/deep/deeper/case.md', '# deep case\n'); + write('skills/demo/references/_index.md', '# generated index\n'); + write('skills/not-a-skill/notes.md', '# a directory with no SKILL.md\n'); + return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; } function selfTest() { const rel = 'skills/objectstack-ui/SKILL.md'; const over = verdict(rel, 26000, 25154).msg; const unpriced = verdict('skills/objectstack-new/SKILL.md', 4000, undefined).msg; - const discovered = discoverSkillFiles(); + const discovered = discoverBundleFiles(); + const authored = discovered.filter((p) => !generatorOwning(p)); + const generated = discovered.filter((p) => generatorOwning(p)); + + // The #12392 boundary fixtures, all real paths on this tree. + const genIndex = 'skills/objectstack-ai/references/_index.md'; + const genContract = 'skills/objectstack-ui/contracts/react-blocks.contract.json'; + const authoredRef = 'skills/objectstack-data/references/data-hooks.md'; + const authoredRule = 'skills/objectstack-data/rules/relationships.md'; + const genGreen = verdict(genIndex, 754, undefined, generatorOwning(genIndex)); + const genPriced = verdict(genIndex, 754, 754, generatorOwning(genIndex)); + const unpricedNested = verdict(authoredRule, 3778, undefined, null).msg; + + const fixture = fixtureTree(); + let walked; + try { + walked = discoverBundleFiles(fixture.root); + } finally { + fixture.cleanup(); + } + + const skillMdCeilings = [...CEILINGS.entries()].filter(([p]) => p.endsWith('/SKILL.md')); const cases = [ // ── ratchet semantics ──────────────────────────────────────────────── @@ -361,18 +612,80 @@ function selfTest() { // ── enumeration, never a hand list ─────────────────────────────────── ['the catalog is discovered from disk', discovered.length > 0, true], - ['every discovered file is a published SKILL.md path', - discovered.every((p) => p.startsWith('skills/') && p.endsWith('/SKILL.md')), true], + ['every discovered file lives under a published skill directory', + discovered.every((p) => /^skills\/[^/]+\/.+/.test(p)), true], // The hole the enumeration exists to close: a twelfth published skill must // not land unpriced. A hand list would simply not mention it. ['a discovered file with no ceiling is RED', verdict('skills/objectstack-new/SKILL.md', 4000, undefined).ok, false], ['the unpriced red names the file and its measured count', unpriced.includes('skills/objectstack-new/SKILL.md') && unpriced.includes('4000'), true], - ['every discovered file carries a ceiling today', discovered.filter((p) => !CEILINGS.has(p)), []], + ['every discovered AUTHORED file carries a ceiling today', authored.filter((p) => !CEILINGS.has(p)), []], ['every ceiling names a file the enumeration finds', [...CEILINGS.keys()].filter((p) => !discovered.includes(p)), []], ['every ceiling is a positive integer', [...CEILINGS.values()].every((n) => Number.isInteger(n) && n > 0), true], + // ── #12392: the walk actually recurses ─────────────────────────────── + // The vacuity direction, pinned on a tree this test built: a walk that + // stopped at SKILL.md would pass every real-tree case above by returning + // the 11 files it still found. These fail loudly instead. + ['the walk descends past SKILL.md into rules/', walked.includes('skills/demo/rules/nested.md'), true], + ['the walk descends arbitrarily deep', walked.includes('skills/demo/evals/deep/deeper/case.md'), true], + ['a directory carrying no SKILL.md is skipped whole', + walked.some((p) => p.startsWith('skills/not-a-skill/')), false], + ['skills/README.md is outside the population (population 3)', walked.includes('skills/README.md'), false], + ['the fixture walk finds exactly its four in-skill files', walked.length, 4], + // The same non-vacuity, asserted against the REAL tree: the population must + // be strictly larger than the SKILL.md set it used to be, or #12392's + // extension has silently reverted to what it replaced. + ['the real population is more than one file per skill', authored.length > skillMdCeilings.length, true], + ['the real population reaches rules/ and hand-authored references/', + authored.includes(authoredRule) && authored.includes(authoredRef), true], + + // ── #12392: the authored/generated boundary ────────────────────────── + // The boundary is the register's answer, never a local regexp. These pin + // that the reuse is wired and pointing the right way in BOTH directions. + ['a generated _index.md is recognised as generator-owned', generatorOwning(genIndex)?.id, 'spec-skill-refs'], + ['the react-blocks contract is recognised as generator-owned', generatorOwning(genContract)?.id, 'spec-react-blocks'], + ['a hand-authored reference is NOT generator-owned', generatorOwning(authoredRef), null], + ['a hand-authored rule is NOT generator-owned', generatorOwning(authoredRule), null], + ['a SKILL.md is NOT generator-owned', generatorOwning(rel), null], + // Generated files are measured, never ratcheted — and the green says so. + ['a generator-owned file with no ceiling is GREEN, not an unpriced red', genGreen.ok, true], + ['its green names the generator that owns it', genGreen.msg.includes('gen:skill-refs'), true], + ['its green says measured-but-not-ratcheted', genGreen.msg.includes('not ratcheted'), true], + // The contradiction guard: pricing a generated file reds on regeneration, + // so the row itself is the error, caught here rather than by the regen. + ['a CEILINGS row on a generator-owned file is RED', genPriced.ok, false], + ['that red explains the wrong-actor reason', genPriced.msg.includes('regeneration'), true], + ['no ceiling names a generator-owned path', [...CEILINGS.keys()].filter((p) => generatorOwning(p)), []], + // The vacuity direction for the boundary predicate itself: a predicate that + // answered "generated" for everything would price NOTHING and stay green. + ['the boundary does not swallow the population', authored.length > 0, true], + ['the boundary excludes something (it is not inert)', generated.length > 0, true], + // THE TRIPWIRE (see the header's "known limit of the reuse"). The excluded + // set is pinned by name, so a hand-authored file that happens to match a + // generated spelling cannot slip out of pricing unnoticed — it changes this + // list, reds here, and gets a human's eyes. + ['the excluded set is exactly these generator-owned paths', generated, [ + 'skills/objectstack-ai/references/_index.md', + 'skills/objectstack-api/references/_index.md', + 'skills/objectstack-automation/references/_index.md', + 'skills/objectstack-data/references/_index.md', + 'skills/objectstack-formula/references/_index.md', + 'skills/objectstack-i18n/references/_index.md', + 'skills/objectstack-platform/references/_index.md', + 'skills/objectstack-query/references/_index.md', + 'skills/objectstack-ui/contracts/react-blocks.contract.json', + 'skills/objectstack-ui/references/_index.md', + 'skills/objectstack-ui/references/react-blocks.md', + ]], + // The extension's own red, on a real newly-priced path: the message has to + // name the authored-bundle rule, not the old SKILL.md-only one. + ['the unpriced red covers a file INSIDE a skill, not just a new skill', + unpricedNested.includes('hand-authored published bundle file'), true], + ['the unpriced red routes a genuine generator output to the register, not to a second list', + unpricedNested.includes('check-governed-merges.mjs') && unpricedNested.includes('never in a second list'), true], + // ── the two-basis pin (#10402) ─────────────────────────────────────── // Enforcement cannot hold this: both shas run perfectly green whatever they // say, because the gate never reads them. Without these cases the recorded @@ -392,16 +705,41 @@ function selfTest() { // The direction, asserted rather than assumed: this re-measure LOWERED the // bundle. A future edit that re-measures upward has to change this number // and meet the maintainer-ruling bar in the header while doing it. - ['the re-measure lowered the bundle total', - [...CEILINGS.values()].reduce((a, b) => a + b, 0) < 117943, true], + // ⚠️ Scoped to the SKILL.md rows since #12392 widened the map: summing ALL + // ceilings would compare this re-measure's subtotal against a total that + // now includes 27 rows it never touched, which is a different claim. + ['the re-measure lowered the SKILL.md subtotal', + skillMdCeilings.reduce((a, [, n]) => a + n, 0) < 117943, true], + + // ── the #12392 extension basis ─────────────────────────────────────── + // Same reasoning as the pins above: the gate never reads this sha, so it + // drifts from the rows it explains in silence unless something pins it. + ['the #12392 extension basis sha is recorded', CEILING_BASIS.bundleExtension, 'c026b0d2d'], + // The extension's whole point, as a number: the priced population is no + // longer one file per skill. If a future edit collapses it back, this is + // the case that says so out loud. + ['the extension priced more than the eleven SKILL.md files', CEILINGS.size > 11, true], + ['every SKILL.md still carries its own ceiling', skillMdCeilings.length, 11], // ── the report line ────────────────────────────────────────────────── - ['the report prints a bundle total', - reportLines([{ rel, tokens: 10, ceiling: 20 }]).some((l) => l.includes('bundle total')), true], + ['the report prints a whole-bundle total', + reportLines([{ rel, tokens: 10, ceiling: 20 }]).some((l) => l.includes('bundle total (whole shipped tree)')), true], ['the report prints a per-file net delta against the ceiling', reportLines([{ rel, tokens: 30, ceiling: 20 }]).some((l) => l.includes('(+10)')), true], ['the report names the counting convention', reportLines([]).some((l) => l.includes(TOKEN_CONVENTION)), true], + // #12392's headline complaint was that this gate could not produce a + // whole-tree delta — its number was byte-identical across a PR that changed + // three published files. A generated file must therefore move the bundle + // total even though it moves no ceiling. + ['a generator-owned file is counted in the bundle total', + reportLines([{ rel: genIndex, tokens: 754, ceiling: undefined, generated: generatorOwning(genIndex) }]) + .some((l) => l.includes('bundle total (whole shipped tree)') && l.includes('754')), true], + ['a generator-owned file is NOT counted into the ratcheted subtotal', + reportLines([{ rel: genIndex, tokens: 754, ceiling: undefined, generated: generatorOwning(genIndex) }]) + .some((l) => l.includes('ratcheted (authored)') && / 0 \/ +0/.test(l)), true], + ['the report separates the generator-owned subtotal', + reportLines([]).some((l) => l.includes('generator-owned (measured, not ratcheted)')), true], ]; let failed = 0;