From ab0e91613f280ace920cda730990e5e8a81f8117 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:24:23 +0000 Subject: [PATCH 1/4] chore(scripts): delete 12 root scripts nothing reaches; audit citations point at the commit that holds them Zero-reference census over the whole tree (file stem and relative path, with scripts/check-nul-bytes.mjs as the positive control). Each deleted file was named only in a docs/audits reading, a CHANGELOG entry, a self-test fixture string or a comment; those mentions now name commit 7111e14ac9, which holds the script. CHANGELOG entries are release-owned and stay as they shipped. Claude-Session: https://claude.ai/code/session_01LbdJFR3AmcKLZKQafYE1tN Co-authored-by: Claude --- ...08-partial-retirement-annotation-signal.md | 6 +- .../2026-09-loadmany-consumer-census.md | 7 +- ...26-09-multi-update-per-row-value-census.md | 2 +- ...-tenancy-ledger-namespace-regexp-census.md | 4 +- ...tenancy-ledger-namespace-regexp-census.mjs | 293 ---- .../14423-c3-c4-keyed-mechanism-probe.mjs | 189 --- .../14423-databaseloader-cost-probe.mjs | 61 - .../14423-filesystemloader-cost-probe.mjs | 49 - ...4423-unbound-declarations-before-count.mjs | 94 -- ...4744-before-update-per-row-value-probe.mjs | 382 ----- scripts/bench/permission-bench.mts | 99 -- scripts/bench/runtime-publish-gate.bench.mts | 612 -------- scripts/measure-durability-swallow-family.mjs | 2 +- scripts/measure-markdown-ts-blocks.mjs | 1276 ----------------- .../measure-partial-retirement-annotation.mjs | 534 ------- .../measure-reserved-identity-name-census.mjs | 400 ------ scripts/pm/dispatch-gates.mjs | 3 +- scripts/pm/measurement-claim-triage.mjs | 653 --------- 18 files changed, 17 insertions(+), 4649 deletions(-) delete mode 100644 scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs delete mode 100644 scripts/audits/14423-c3-c4-keyed-mechanism-probe.mjs delete mode 100644 scripts/audits/14423-databaseloader-cost-probe.mjs delete mode 100644 scripts/audits/14423-filesystemloader-cost-probe.mjs delete mode 100644 scripts/audits/14423-unbound-declarations-before-count.mjs delete mode 100644 scripts/audits/14744-before-update-per-row-value-probe.mjs delete mode 100644 scripts/bench/permission-bench.mts delete mode 100644 scripts/bench/runtime-publish-gate.bench.mts delete mode 100644 scripts/measure-markdown-ts-blocks.mjs delete mode 100644 scripts/measure-partial-retirement-annotation.mjs delete mode 100644 scripts/measure-reserved-identity-name-census.mjs delete mode 100644 scripts/pm/measurement-claim-triage.mjs diff --git a/docs/audits/2026-08-partial-retirement-annotation-signal.md b/docs/audits/2026-08-partial-retirement-annotation-signal.md index 1c46f280d8e..4b992af71d7 100644 --- a/docs/audits/2026-08-partial-retirement-annotation-signal.md +++ b/docs/audits/2026-08-partial-retirement-annotation-signal.md @@ -5,7 +5,8 @@ **Instrument**: `scripts/measure-partial-retirement-annotation.mjs`. Not a gate, not wired into any workflow, not a `check:`/`gen:` script. Committed so the numbers are -reproducible rather than asserted. +reproducible rather than asserted; later removed from the tree and held at commit +`7111e14ac9`. **Mandate**: the maintainer ruling of 2026-08-09 on #6635 — measurement first, no gate is built yet. This document reports total hits, a spot-verified true/false-positive @@ -154,6 +155,9 @@ explicitly so it can be audited. ## 8. Reproducing +The instrument was removed from the tree; run it from a checkout of commit `7111e14ac9`, +which holds it (`git worktree add --detach ../objectstack-7111e14ac9 7111e14ac9`). + ```bash node scripts/measure-partial-retirement-annotation.mjs # summary node scripts/measure-partial-retirement-annotation.mjs --hits # every hit with context diff --git a/docs/audits/2026-09-loadmany-consumer-census.md b/docs/audits/2026-09-loadmany-consumer-census.md index a807f3e6b9e..0184f68eb57 100644 --- a/docs/audits/2026-09-loadmany-consumer-census.md +++ b/docs/audits/2026-09-loadmany-consumer-census.md @@ -96,8 +96,9 @@ files that assert today's unkeyed shape. test-only fixture doubles (`*.test.ts`, not loaders anyone ships). Each shipped loader's `list()`, `loadMany()` and `loadManyKeyed()` were read at the cited lines, then the cost claim was **measured**, not guessed — either by counting the calls a fake engine/fs sees, -or by timing the real implementation. Scripts are committed under `scripts/audits/` (see -§5) so every number below is re-runnable. +or by timing the real implementation. Scripts were committed under `scripts/audits/` (see +§5) so every number below is re-runnable; they were later removed from the tree and are +held at commit `7111e14ac9`. | Loader | `listNames` (via `list()`) | `loadManyKeyed` | Cost of `listNames()` + N × by-name `load`/`loadDiagnosed`, vs `loadMany()` | |---|---|---|---| @@ -289,6 +290,8 @@ direct a re-check once the rate limit clears) rather than risk a duplicate. ## Commands to re-run every measurement in this census ```bash +# The four scripts were removed from the tree; run these from a checkout of commit +# 7111e14ac9, which holds them: git worktree add --detach ../objectstack-7111e14ac9 7111e14ac9 # Build the dependency closures this census's scripts import from (dist, not src): pnpm --filter '@objectstack/objectql^...' build && pnpm --filter @objectstack/objectql build diff --git a/docs/audits/2026-09-multi-update-per-row-value-census.md b/docs/audits/2026-09-multi-update-per-row-value-census.md index 30ec7d65e73..e780e4edea7 100644 --- a/docs/audits/2026-09-multi-update-per-row-value-census.md +++ b/docs/audits/2026-09-multi-update-per-row-value-census.md @@ -165,7 +165,7 @@ SELF-TEST PASSED — 10/10 cases ## 3. Instrument B — runtime behavioural probe on the real engine `scripts/audits/14744-before-update-per-row-value-probe.mjs` -(`npx tsx … --out `). It boots the real `ObjectQL` against a stub driver, +(`npx tsx … --out `; removed from the tree after this reading; held at commit `7111e14ac9`). It boots the real `ObjectQL` against a stub driver, dispatches handlers per row of a genuine `multi: true` update, and reads what actually reaches `driver.updateMany` — the one `SET` clause D3 gives N rows. **Four of its eight subjects are the shipped handlers themselves, imported and dispatched unmodified**; the diff --git a/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md b/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md index 140f69b9c81..fa2f5df5ccc 100644 --- a/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md +++ b/docs/audits/2026-09-tenancy-ledger-namespace-regexp-census.md @@ -77,7 +77,7 @@ which only *mention* the call in comments), filtered to a literal `name: '...'` matching — a first pass without that step undercounted by 2 (`sys_oauth_client_resource`, `sys_oauth_resource`), both because a doc comment shaped like `(255, 768]` desyncs a naive bracket scan; see the script's own header comment for detail). -Script: `scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs`. +Script: `scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs` (removed from the tree after this reading; held at commit `7111e14ac9`). **Result: 84 registered platform-namespace objects** — all `sys_`-prefixed; zero `cloud_` or `ai_` object schemas exist in this repository (the ledger's own comment explains why: @@ -282,6 +282,8 @@ under `scripts/audits/` — no exported symbol, no published payload key, nothin # Build the dependency closure the script imports from (dist, not src): pnpm --filter '@objectstack/objectql^...' build && pnpm --filter @objectstack/objectql build +# The script was removed from the tree; run these from a checkout of commit 7111e14ac9, +# which holds it: git worktree add --detach ../objectstack-7111e14ac9 7111e14ac9 # The whole census, in one command — prints the full JSON report to stdout # and a human-readable summary to stderr; exits 0 (measurement, not a gate): node scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs diff --git a/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs b/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs deleted file mode 100644 index 4e71b194d95..00000000000 --- a/scripts/audits/14096-tenancy-ledger-namespace-regexp-census.mjs +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #14096 step 1 (census) — the maintainer ruling's whole first deliverable - * (director seat, 总监批 #25, 2026-09-01, maintainer verbatim 「同意」, issue - * comment 5494594783): does `PLATFORM_OBJECT_TENANCY` (the hand-adjudicated - * ledger the RUNTIME write path reads, `packages/objectql/src/tenancy/ - * platform-object-tenancy.ts`) judge any platform-namespace object - * DIFFERENTLY than the namespace regexp `/^(sys_|cloud_|ai_)/` (what the two - * SEED paths — `seed-loader.ts`'s `fallbackOrgId` and - * `seed-tenancy-backfill.ts`'s own `PLATFORM_NAMESPACE` — still cut by)? - * - * MEASUREMENT ONLY on a healthy read: exit 0, this is a census, not a gate. - * Requires the `@objectstack/objectql` dependency closure built (`pnpm - * --filter '@objectstack/objectql^...' build && pnpm --filter - * @objectstack/objectql build`) — this script does not build. - * - * ⚠️ NOT measurement-only on a BROKEN ledger read. `disagreement_count` is - * downstream of `PLATFORM_OBJECT_TENANCY`, the one input this script does not - * independently re-derive (§2's header control is an AST census of the - * OBJECT FILES — it never reads this ledger, so it validates the population - * and tenant-field logic and proves nothing about the ledger import). An - * emptied, stale, or mis-imported ledger therefore reports a clean - * `disagreement_count: 0` indistinguishable from a genuine zero, WHILE - * `reproduced_matches_header` still prints `true` — reproduced against a PR - * #15122 reviewer experiment: `PLATFORM_OBJECT_TENANCY = {}`, rebuilt, run; - * exit 0, header control "passes", 0 disagreements. Because the ruling this - * script serves branches on the number (a zero closes #14096 as "keep the - * regexp", non-zero re-opens it p1 — see the audit doc's `## Branch - * verdict`), a false zero would close a p1 tenancy question as decided with - * no visible sign anything had gone wrong. `LEDGER_ENTRY_COUNT_FLOOR` below - * turns that failure mode into a thrown error (non-zero exit) instead. - * - * ## The predicate (stated, not left implicit — dispatch Zone 2.1) - * - * The regexp is used at both seed sites as a UNIFORM rule: every name it - * matches stays global/cross-tenant (no fallback org at seed-load, no - * backfill adoption) — confirmed by reading both sites (see §3 below). So - * "the regexp's verdict" for every object IN the population this script - * builds (every registered platform-namespace object — the population is - * defined BY the regexp match) is uniformly "out of scope" (global). - * - * "The ledger's verdict" is `classifyPlatformObjectTenancy(name)`, which - * answers one of three states: `tenant-scoped` (in scope — the runtime path - * derives/refuses an organization for it), `global`, or `unclassified` (not - * adjudicated — stays out of scope, same as `global`, per the ledger's own - * documented policy). - * - * The honest mapping onto the regexp's binary: `tenant-scoped` -> "in scope" - * (disagrees with the regexp's uniform "out of scope"); `global` and - * `unclassified` both -> "out of scope" (agree with the regexp). A disagreement - * is therefore exactly a ledger entry classified `tenant-scoped` — nothing - * about `global` vs `unclassified` changes which population disagrees, since - * both map to the SAME binary answer the regexp gives every one of them. - * - * ## The trap this script does NOT fall into (dispatch Zone 2.2) - * - * `resolveTenantFieldName` never reads `managedBy`. Three better-auth-managed - * objects (`sys_member`, `sys_team`, `sys_invitation`) declare their OWN - * `organization_id` field despite `managedBy: 'better-auth'`, so they resolve - * a real tenant column (`declaresOrgId` below checks the object's OWN - * `fields:` block for a literal `organization_id: Field...`, not `managedBy`). - * This matters for the header control (§2) — it does NOT change the - * disagreement count (§4), because none of the three is in the ledger at all - * (`unclassified`), and `unclassified` already maps to "out of scope" same as - * `global` above. The script still computes it, printed for that control. - * - * ## A second precedence subtlety, caught only by failing to reproduce the - * header on a first pass (exactly the failure mode §2's own comment warns - * about, so kept as a comment rather than quietly fixed) - * - * `resolveTenantFieldName` checks `tenancy.enabled === false` FIRST and - * returns `null` UNCONDITIONALLY on it — before it ever looks at `fields`. - * So an object can carry `tenancy: { enabled: false }` **and** its own - * declared `organization_id` field (`sys_sso_provider` does exactly this) - * and still resolve NO tenant field: the explicit opt-out overrides a - * self-declared column, it does not merely withhold injection of one. A - * first draft of `hasResolvableTenantField` below read `declaresOrgId` - * through that branch (treating the opt-out like the `managedBy` skip, - * which does NOT override a self-declared field) and undercounted - * `no_tenant_field` by one (24 instead of 25) against the header control — - * caught by §2's own mismatch check, not by review. - */ - -import { readFileSync } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(HERE, '../..'); - -// --------------------------------------------------------------------------- -// §1. Discover every registered platform-namespace object by AST-lite census -// of `ObjectSchema.create(` calls — the same method the ledger's own -// header (`packages/objectql/src/tenancy/platform-object-tenancy.ts`, its -// opening comment — lines 32-37 as measured) documents using. -// --------------------------------------------------------------------------- - -/** Blank out `//` and `/* *\/` comments and string contents (chars only, - * preserving quote delimiters) so a paren mentioned in PROSE ("(255, 768]") - * cannot desync a naive bracket-depth scan. This is the exact bug that made - * an earlier pass of this census undercount by 2 (`sys_oauth_client_resource`, - * `sys_oauth_resource` — both carry a numeric-range comment shaped like - * `(N, M]`) — left in as a comment because the failure mode is easy to - * reintroduce by "simplifying" this function. */ -function blankNonCode(src) { - let out = ''; - let i = 0; - const n = src.length; - let inSL = false, inML = false, inStr = null; - while (i < n) { - const c = src[i], c2 = src[i + 1]; - if (inSL) { out += c === '\n' ? c : ' '; if (c === '\n') inSL = false; i++; continue; } - if (inML) { if (c === '*' && c2 === '/') { out += ' '; i += 2; inML = false; continue; } out += c === '\n' ? c : ' '; i++; continue; } - if (inStr) { out += c; if (c === '\\') { out += src[i + 1] ?? ''; i += 2; continue; } if (c === inStr) inStr = null; i++; continue; } - if (c === '/' && c2 === '/') { inSL = true; out += ' '; i += 2; continue; } - if (c === '/' && c2 === '*') { inML = true; out += ' '; i += 2; continue; } - if (c === '"' || c === "'" || c === '`') { inStr = c; out += c; i++; continue; } - out += c; i++; - } - return out; -} - -function findObjectSchemaCreateCalls() { - const files = execSync( - `grep -rl "ObjectSchema.create(" packages/ --include="*.ts" | grep -v "\\.test\\.\\|node_modules\\|/migrations/registry.ts\\|/migrations/entries/"`, - { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 1024 * 1024 * 50 }, - ).trim().split('\n').filter(Boolean); - - const results = []; - for (const file of files) { - const rawSrc = readFileSync(resolve(REPO_ROOT, file), 'utf8'); - const src = blankNonCode(rawSrc); - let idx = 0; - while (true) { - const start = src.indexOf('ObjectSchema.create(', idx); - if (start === -1) break; - const parenStart = src.indexOf('(', start + 'ObjectSchema.create'.length - 1); - let depth = 0, i = parenStart, end = -1; - for (; i < src.length; i++) { - if (src[i] === '(') depth++; - else if (src[i] === ')') { depth--; if (depth === 0) { end = i; break; } } - } - if (end === -1) { idx = start + 1; continue; } - const rawBlock = rawSrc.slice(parenStart, end + 1); - const m = rawBlock.match(/name:\s*['"]([a-zA-Z0-9_]+)['"]/); - if (m && /^(sys_|cloud_|ai_)/.test(m[1])) { - results.push({ - name: m[1], - file, - managedBy: rawBlock.match(/managedBy:\s*['"]([a-zA-Z0-9_-]+)['"]/)?.[1] ?? null, - tenancyEnabledFalse: /tenancy:\s*\{[^}]*enabled:\s*false/s.test(rawBlock), - systemFieldsFalse: /systemFields:\s*false/.test(rawBlock), - systemFieldsTenantFalse: /systemFields:\s*\{[^}]*tenant:\s*false/s.test(rawBlock), - declaresOrgId: /\borganization_id\s*:\s*Field\./.test(rawBlock), - }); - } - idx = end + 1; - } - } - return results; -} - -// --------------------------------------------------------------------------- -// §2. Reproduce the ledger header's positive control (84 / 25 / 59) — -// dispatch Zone 2.3: "if your own reading cannot reproduce the header's -// numbers, your instrument is wrong, not the header." -// -// Replicates `applySystemFields`'s real precedence -// (`packages/objectql/src/registry.ts`): the `organization_id` COLUMN is -// injected UNCONDITIONALLY except for `tenancy.enabled: false`, -// `systemFields: false`, `systemFields.tenant: false`, or -// `managedBy: 'better-auth'` (whose column layout is better-auth's own -// migrations) — and even then, an object that already DECLARES its own -// `organization_id` field keeps it regardless of the skip. -// --------------------------------------------------------------------------- -function hasResolvableTenantField(entry) { - // `resolveTenantFieldName`'s FIRST check, and it is UNCONDITIONAL: an - // explicit `tenancy.enabled: false` wins even over a self-declared - // `organization_id` field (see the module doc comment above). - if (entry.tenancyEnabledFalse) return false; - // Past that gate, a self-declared field always counts — this is what - // keeps `sys_member`/`sys_team`/`sys_invitation` OUT of this branch's - // false side despite `managedBy: 'better-auth'` (the Zone-2.2 trap). - if (entry.declaresOrgId) return true; - if (entry.managedBy === 'better-auth') return false; // injection skipped, no self-declared field - if (entry.systemFieldsFalse) return false; - if (entry.systemFieldsTenantFalse) return false; - return true; // injected unconditionally otherwise -} - -// --------------------------------------------------------------------------- -// §3. Confirm the two seed sites still cut by the SAME uniform regexp rule -// (dispatch Zone 2.5 — re-derive, don't assume). -// --------------------------------------------------------------------------- -function confirmSeedSitesCutByRegexp() { - const seedLoader = readFileSync(resolve(REPO_ROOT, 'packages/metadata-protocol/src/seed-loader.ts'), 'utf8'); - const backfill = readFileSync(resolve(REPO_ROOT, 'packages/metadata-protocol/src/migrations/seed-tenancy-backfill.ts'), 'utf8'); - const seedLoaderCuts = /\(\/\^\(sys_\|cloud_\|ai_\)\/\.test\(objectName\)\s*\?\s*undefined\s*:\s*this\.fallbackOrgId\)/.test(seedLoader); - const backfillDeclares = /const PLATFORM_NAMESPACE = \/\^\(sys_\|cloud_\|ai_\)\//.test(backfill); - const backfillFilters = /\.filter\(\(r\)\s*=>\s*!PLATFORM_NAMESPACE\.test\(String\(r\.object\)\)\)/.test(backfill); - return { seedLoaderCuts, backfillDeclares, backfillFilters }; -} - -// --------------------------------------------------------------------------- -// §4. The ledger's real verdict, from the REAL built module — not -// re-transcribed by hand. -// --------------------------------------------------------------------------- -async function main() { - const entries = findObjectSchemaCreateCalls(); - const names = entries.map((e) => e.name).sort(); - - const withTenantField = entries.filter(hasResolvableTenantField).map((e) => e.name).sort(); - const withoutTenantField = entries.filter((e) => !hasResolvableTenantField(e)).map((e) => e.name).sort(); - - const seedSites = confirmSeedSitesCutByRegexp(); - - const { classifyPlatformObjectTenancy, PLATFORM_OBJECT_TENANCY, isPlatformNamespaceObject } = - await import(resolve(REPO_ROOT, 'packages/objectql/dist/index.js')); - - // Hard floor on the ledger read (see the module doc comment above for why - // this is not optional): an empty, stale, or mis-imported - // `PLATFORM_OBJECT_TENANCY` must fail LOUDLY, not report a clean - // `disagreement_count: 0` that is indistinguishable from a genuine zero. - // As of 2026-09-04 the ledger holds 9 entries (8 tenant-scoped + - // `sys_permission_set` global) -- §5 of the audit doc. The floor is set - // well below that so a handful of future legitimate edits (a new verdict - // added, or even one or two retired) does not trip it, while zero -- and - // anything close enough to zero to be a broken read rather than a real - // ledger -- does. If the ledger legitimately shrinks below this floor, - // raise it deliberately in its own PR; do not lower it to silence a real - // failure. - const LEDGER_ENTRY_COUNT_FLOOR = 5; - const ledgerEntryCount = Object.keys(PLATFORM_OBJECT_TENANCY).length; - if (ledgerEntryCount < LEDGER_ENTRY_COUNT_FLOOR) { - throw new Error( - `PLATFORM_OBJECT_TENANCY imported with only ${ledgerEntryCount} ` + - `${ledgerEntryCount === 1 ? 'entry' : 'entries'} (floor: ${LEDGER_ENTRY_COUNT_FLOOR}). ` + - 'This looks like an empty, stale, or mis-imported ledger, not a real low-entry ' + - 'state -- refusing to compute a disagreement count against it, because a false ' + - 'zero here would silently close a p1 tenancy question as "decided, nothing to ' + - 'do" (see the module doc comment and the audit doc\'s `## Branch verdict`). ' + - 'Rebuild the @objectstack/objectql dependency closure (see the exact commands in ' + - "this file's module doc comment, or the audit doc's \"Commands to re-run this " + - 'census\" section) and re-run. If the ledger has legitimately shrunk below ' + - `${LEDGER_ENTRY_COUNT_FLOOR} entries, raise LEDGER_ENTRY_COUNT_FLOOR deliberately -- ` + - 'do not lower it to make a real failure go away.', - ); - } - - const allMatchRegexp = names.every((n) => isPlatformNamespaceObject(n)); - - const disagreements = names - .map((name) => ({ name, ledger: classifyPlatformObjectTenancy(name) })) - .filter((r) => r.ledger === 'tenant-scoped'); - - // The trap-check objects named in the dispatch (Zone 2.2): resolve a real - // tenant field despite `managedBy: 'better-auth'`, but are NOT in the - // ledger (unclassified) -- so they do not appear in `disagreements` above. - const trapObjects = ['sys_member', 'sys_team', 'sys_invitation'].map((n) => ({ - name: n, - resolvesTenantField: withTenantField.includes(n), - ledgerVerdict: classifyPlatformObjectTenancy(n), - })); - - const report = { - population_size: names.length, - header_claims: { total: 84, no_tenant_field: 25, has_tenant_field: 59 }, - reproduced: { total: names.length, no_tenant_field: withoutTenantField.length, has_tenant_field: withTenantField.length }, - reproduced_matches_header: - names.length === 84 && withoutTenantField.length === 25 && withTenantField.length === 59, - all_discovered_names_match_regexp: allMatchRegexp, - seed_sites_still_cut_by_regexp: seedSites, - ledger_entry_count: ledgerEntryCount, - disagreement_count: disagreements.length, - disagreements: disagreements.map((d) => d.name), - trap_check: trapObjects, - no_tenant_field_objects: withoutTenantField, - }; - - console.log(JSON.stringify(report, null, 2)); - console.error('\n--- human-readable summary ---'); - console.error(`Population (registered platform-namespace objects): ${report.population_size}`); - console.error(`Header claims 84/25/59; reproduced ${report.reproduced.total}/${report.reproduced.no_tenant_field}/${report.reproduced.has_tenant_field} -> ${report.reproduced_matches_header ? 'MATCH (control passes)' : 'MISMATCH (investigate before trusting anything else in this report)'}`); - console.error(`Both seed sites still cut by the uniform namespace regexp: ${JSON.stringify(seedSites)}`); - console.error(`Ledger vs regexp disagreement count: ${report.disagreement_count}`); - console.error(`Disagreeing objects: ${report.disagreements.join(', ') || '(none)'}`); - console.error(`Trap check (resolve a tenant field despite managedBy:'better-auth', but ledger-unclassified so NOT a disagreement): ${JSON.stringify(trapObjects)}`); -} - -main().catch((e) => { console.error(e); process.exitCode = 1; }); diff --git a/scripts/audits/14423-c3-c4-keyed-mechanism-probe.mjs b/scripts/audits/14423-c3-c4-keyed-mechanism-probe.mjs deleted file mode 100644 index ee290fda819..00000000000 --- a/scripts/audits/14423-c3-c4-keyed-mechanism-probe.mjs +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #14423 step 1 (census) — does a KEYED read (`listNames()` + by-name - * `load`/`loadDiagnosed`) close C3 and C4 from - * `packages/runtime/src/action-governance-scope-divergence.test.ts`? - * - * MEASUREMENT ONLY. This script changes nothing in any shipped package; it - * imports the real, already-built `@objectstack/metadata` and - * `@objectstack/core` from their `dist/` output and drives the SAME doubles - * the pinned fixture uses (`readEngine` / `listDownEngine`), transcribed here - * rather than imported because the fixture is a `.test.ts` file with no - * public export surface — see that file's own header for what is real and - * what is doubled. - * - * Re-run with: `node scripts/audits/14423-c3-c4-keyed-mechanism-probe.mjs` - * (requires `pnpm --filter '@objectstack/objectql^...' build` — the - * dependency closure that puts `@objectstack/metadata` / `@objectstack/core` - * dist output on disk — to have been run first; this script does not build). - * - * Findings are printed as JSON on stdout; a human-readable summary goes to - * stderr. Exit code is always 0 — this is a measurement, not a gate. - */ - -import { MetadataManager } from '../../packages/metadata/dist/index.js'; -import { DatabaseLoader } from '../../packages/metadata/dist/index.js'; -import { ObjectKernel, ServiceLifecycle } from '../../packages/core/dist/index.js'; - -const ACTION = 'promote_lead'; - -/** Transcribed from action-governance-scope-divergence.test.ts — see its header. */ -function readEngine(rows) { - const matches = (r, w) => - Object.entries(w).every(([k, v]) => { - if (k.startsWith('$')) throw new Error(`readEngine: unsupported WHERE combinator '${k}'`); - if (v !== null && typeof v === 'object') throw new Error(`readEngine: unsupported WHERE operator on '${k}'`); - return r[k] === v; - }); - return { - async find(table, q) { - void table; - const hits = rows.filter((r) => matches(r, q?.where ?? {})); - return typeof q?.limit === 'number' ? hits.slice(0, q.limit) : hits; - }, - async findOne(_table, q) { - return rows.find((r) => matches(r, q?.where ?? {})) ?? null; - }, - async count(_table, q) { - return rows.filter((r) => matches(r, q?.where ?? {})).length; - }, - }; -} - -/** Transcribed from the same fixture: the LIST read down, findOne intact. */ -function listDownEngine(rows) { - return { - ...readEngine(rows), - async find() { - throw Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432'), { code: 'ECONNREFUSED' }); - }, - }; -} - -function row(name, body) { - return { - id: `md_${name}`, - name, - type: 'action', - namespace: 'default', - scope: 'platform', - state: 'active', - version: 1, - metadata: JSON.stringify(body), - }; -} - -function planeOver(rows, engine = readEngine(rows)) { - const mgr = new MetadataManager({}); - mgr.registerLoader(new DatabaseLoader({ - engine, - trackHistory: false, - cache: { enabled: false }, - })); - return mgr; -} - -/** The audit's OWN swallow, transcribed from `collectEngineActionDeclarations` - * (`packages/objectql/src/action-governance.ts`) — the outer try/catch a - * throwing plural read falls into today. */ -async function swallowedStandaloneRead(thunk) { - try { - return { standalone: (await thunk()) ?? [], threw: false }; - } catch (e) { - return { standalone: [], threw: true, error: e instanceof Error ? e.message : String(e) }; - } -} - -const results = { c3: {}, c4: {} }; - -// --------------------------------------------------------------------------- -// C3 — plural read (loadMany / listNames) fails, by-name read (load / -// loadDiagnosed) answers. Does swapping loadMany -> listNames CLOSE the -// audit's blindness, or just relocate where the same failure is swallowed? -// --------------------------------------------------------------------------- -{ - const rows = [row(ACTION, { name: ACTION, type: 'script', target: ACTION })]; - const meta = planeOver(rows, listDownEngine(rows)); - - // Control, matches the pinned fixture: loadMany catches internally, never throws. - const viaLoadMany = await swallowedStandaloneRead(() => meta.loadMany('action')); - results.c3.loadMany = { threw: viaLoadMany.threw, standaloneLength: viaLoadMany.standalone.length }; - - // Candidate: listNames() — does MetadataManager.listNames() have the same - // per-loader try/catch loadMany()/list() have? (metadata-manager.ts's - // `listNames` loop calls `loader.list(type)` with NO surrounding try/catch.) - const viaListNames = await swallowedStandaloneRead(() => meta.listNames('action')); - results.c3.listNames = { - threwInsideListNames: viaListNames.threw, - errorMessage: viaListNames.error, - standaloneLengthAfterOuterSwallow: viaListNames.standalone.length, - }; - - // The by-name read DOES answer (findOne is untouched by listDownEngine) — - // confirms the divergence is real: the store can answer "does X exist" while - // it cannot answer "list everything". - const byName = await meta.loadDiagnosed('action', ACTION); - results.c3.byNameLoadDiagnosed = { dataPresent: byName.data != null, degraded: byName.degraded }; - - // The MECHANISM the ruling asks the census to name if keying alone does not - // close C3: skip enumeration entirely and probe BY THE ALREADY-KNOWN - // REGISTERED HANDLER NAME, mirroring the router and `dropHandlersDeclaredInRegistry`'s - // registry-rung probe. This is unaffected by list-path failures because it - // never calls list()/loadMany() at all. - const byRegisteredName = await meta.loadDiagnosed('action', ACTION); - results.c3.perRegisteredHandlerProbe = { - wouldCoverTheHandler: byRegisteredName.data != null, - note: 'probes ONLY the names already in `registered` (the engine action map) — never enumerates', - }; -} - -// --------------------------------------------------------------------------- -// C4 — env-scoped `metadata`: the audit's OWN service lookup throws before any -// read method runs. No choice of read method (loadMany / listNames / by-name) -// changes this — the failure is upstream of loadMany entirely. -// --------------------------------------------------------------------------- -{ - const kernel = new ObjectKernel({}); - const perEnv = new Map(); - kernel.registerServiceFactory( - 'metadata', - (_ctx, scopeId) => { - const key = scopeId ?? ''; - if (!perEnv.has(key)) { - perEnv.set(key, planeOver(key === 'env_a' - ? [row(ACTION, { name: ACTION, type: 'script', target: ACTION })] - : [])); - } - return perEnv.get(key); - }, - ServiceLifecycle.SCOPED, - ); - - let auditLookupThrew = false; - let auditLookupError; - try { - kernel.getService('metadata'); - } catch (e) { - auditLookupThrew = true; - auditLookupError = e instanceof Error ? e.message : String(e); - } - results.c4.auditServiceLookup = { threw: auditLookupThrew, error: auditLookupError }; - - const routerMeta = await kernel.getServiceAsync('metadata', 'env_a'); - const routerByName = await routerMeta.loadDiagnosed('action', ACTION); - results.c4.routerServiceLookup = { - resolvedAnInstance: routerMeta != null, - byNameAnswers: routerByName.data != null, - }; - results.c4.conclusion = - 'the audit never reaches ANY read call — getService(unscoped) throws before loadMany, ' + - 'listNames, or load/loadDiagnosed is ever invoked, so swapping the READ METHOD cannot ' + - 'close C4. Only reaching the SCOPED instance (a different accessor, or iterating known ' + - 'scopeIds) would.'; -} - -console.error('--- #14423 C3/C4 keyed-mechanism probe: human summary ---'); -console.error(JSON.stringify(results, null, 2)); -console.log(JSON.stringify(results)); diff --git a/scripts/audits/14423-databaseloader-cost-probe.mjs b/scripts/audits/14423-databaseloader-cost-probe.mjs deleted file mode 100644 index f3a5822d7c0..00000000000 --- a/scripts/audits/14423-databaseloader-cost-probe.mjs +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #14423 step 1 (census) — empirical confirmation that `DatabaseLoader.loadManyKeyed()` -// costs NOTHING extra over `loadMany()` (same query, same cache — -// `packages/metadata/src/loaders/database-loader.ts#readTypeRows`, `#list`), while -// a `listNames()` + per-name `load()` read costs a SEPARATE query plus one query -// PER NAME (`load()`'s `_findOne`, via `baseFilter(type,name)`) — a real N+1 against -// `loadMany`'s single query. MEASUREMENT ONLY — ships nothing. - -import { MetadataManager } from '../../packages/metadata/dist/index.js'; -import { DatabaseLoader } from '../../packages/metadata/dist/index.js'; - -function countingEngine(rows) { - const calls = { find: 0, findOne: 0 }; - const matches = (r, w) => Object.entries(w).every(([k, v]) => r[k] === v); - return { - calls, - async find(_t, q) { calls.find++; return rows.filter((r) => matches(r, q?.where ?? {})); }, - async findOne(_t, q) { calls.findOne++; return rows.find((r) => matches(r, q?.where ?? {})) ?? null; }, - async count(_t, q) { return rows.filter((r) => matches(r, q?.where ?? {})).length; }, - }; -} - -function row(name, body) { - return { id: `md_${name}`, name, type: 'action', namespace: 'default', scope: 'platform', state: 'active', version: 1, metadata: JSON.stringify(body) }; -} - -const NAMES = ['a1', 'a2', 'a3', 'a4', 'a5']; -const rows = NAMES.map((n) => row(n, { name: n, type: 'script', target: n })); - -// Scenario A: loadManyKeyed alone, FRESH loader/cache. -{ - const engine = countingEngine(rows); - const loader = new DatabaseLoader({ engine, trackHistory: false, cache: { enabled: true } }); - await loader.loadManyKeyed('action'); - console.log('loadManyKeyed alone:', JSON.stringify(engine.calls)); -} - -// Scenario B: loadMany alone, FRESH loader/cache — same query shape as A? -{ - const engine = countingEngine(rows); - const loader = new DatabaseLoader({ engine, trackHistory: false, cache: { enabled: true } }); - await loader.loadMany('action'); - console.log('loadMany alone: ', JSON.stringify(engine.calls)); -} - -// Scenario C: listNames() (MetadataManager, one loader) + per-name load() for -// every name it reported — the candidate "keyed read" shape from the ruling. -{ - const engine = countingEngine(rows); - const loader = new DatabaseLoader({ engine, trackHistory: false, cache: { enabled: true } }); - const mgr = new MetadataManager({}); - mgr.registerLoader(loader); - const names = await mgr.listNames('action'); - for (const n of names) { - await mgr.loadDiagnosed('action', n); - } - console.log(`listNames + ${names.length}x loadDiagnosed:`, JSON.stringify(engine.calls), - `(1 list-query + ${names.length} point-queries = ${1 + names.length} total, vs loadMany's 1)`); -} diff --git a/scripts/audits/14423-filesystemloader-cost-probe.mjs b/scripts/audits/14423-filesystemloader-cost-probe.mjs deleted file mode 100644 index 978fc46b90f..00000000000 --- a/scripts/audits/14423-filesystemloader-cost-probe.mjs +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #14423 step 1 (census) — real FilesystemLoader/NodeMetadataManager timing: -// loadMany() (one glob + N reads) vs listNames() + N x loadDiagnosed() (one -// EXTRA glob, then N x [up to K fs.access stats + one read] via findFile()). -// MEASUREMENT ONLY — ships nothing, no source changed. - -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { NodeMetadataManager } from '../../packages/metadata/dist/node.js'; - -const N = 50; -const root = await mkdtemp(join(tmpdir(), 'os-14423-fsloader-cost-')); -await mkdir(join(root, 'action'), { recursive: true }); -for (let i = 0; i < N; i++) { - await writeFile(join(root, 'action', `action_${i}.json`), JSON.stringify({ type: 'script', target: `action_${i}` }), 'utf8'); -} - -const meta = new NodeMetadataManager({ rootDir: root }); - -// Warm the OS file-stat cache identically for both measurements: run each -// scenario twice, keep the SECOND timing, so page-cache effects don't favor -// whichever scenario runs first. -async function timeLoadMany() { - const t0 = performance.now(); - const items = await meta.loadMany('action'); - return { ms: performance.now() - t0, count: items.length }; -} -async function timeListNamesPlusLoad() { - const t0 = performance.now(); - const names = await meta.listNames('action'); - for (const n of names) await meta.loadDiagnosed('action', n); - return { ms: performance.now() - t0, count: names.length }; -} - -await timeLoadMany(); await timeListNamesPlusLoad(); // warm-up, discarded -const a = await timeLoadMany(); -const b = await timeListNamesPlusLoad(); - -console.log(JSON.stringify({ - itemCount: N, - loadMany: a, - listNamesPlusLoad: b, - ratio: (b.ms / a.ms).toFixed(2), -}, null, 2)); - -await rm(root, { recursive: true, force: true }); diff --git a/scripts/audits/14423-unbound-declarations-before-count.mjs b/scripts/audits/14423-unbound-declarations-before-count.mjs deleted file mode 100644 index 1e9011ded32..00000000000 --- a/scripts/audits/14423-unbound-declarations-before-count.mjs +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * #14423 step 1 (census) — the `unboundDeclarations` BEFORE count the - * maintainer ruling (comment 5528592646) asks step 2 to diff against. - * - * MEASUREMENT ONLY. Imports the real, already-built `@objectstack/objectql` - * (`runActionGovernanceInventory`, `collectEngineActionDeclarations`, - * `reconcileActionRegistrations` — all exported from its public entry) and - * drives it exactly as `ObjectQLPlugin.runGovernanceInventory` - * (`packages/objectql/src/plugin.ts#runGovernanceInventory`) does, over a controlled standalone - * source that isolates the ONE population the identity fix can move: - * standalone `action` declarations whose BODY carries no `name` (the C2/C6 - * shape) and are otherwise `type: 'script'` with no `body` and no bound - * handler — the exact shape `unboundDeclarations` reports. - * - * Re-run: `node scripts/audits/14423-unbound-declarations-before-count.mjs` - * (requires the objectql dependency closure built — this script does not - * build). Exit 0 always; this is a measurement, not a gate. - */ - -import { - runActionGovernanceInventory, - collectEngineActionDeclarations, - reconcileActionRegistrations, -} from '../../packages/objectql/dist/index.js'; - -function silentLogger() { - const warnings = []; - return { warnings, logger: { warn: (m, meta) => warnings.push({ m, meta }), debug: () => {} } }; -} - -// --------------------------------------------------------------------------- -// The population the fix can move: N standalone `action` items whose body has -// NO `name` (the C2/C6 shape — identity lives only in the store's key, which -// this thunk, matching TODAY's wiring, never surfaces), type `script`, no -// `body`, and NO registered handler under any key — the exact -// `unboundDeclarations` shape (ADR-0078: "a button wired to nothing"). -// --------------------------------------------------------------------------- -const NAMELESS_ORPHAN_COUNT = 5; -const namelessStandaloneOrphans = Array.from({ length: NAMELESS_ORPHAN_COUNT }, (_, i) => ({ - // Deliberately NO `name` field — the C2/C6 shape. `target`/`objectName` are - // also absent: an orphan script action has nothing to be bound BY, which is - // exactly why it would (if admitted) show up in `unboundDeclarations`. - type: 'script', - // `_storeKey` documents what identity this item WOULD have if the plane - // were asked by key (listNames()/loadManyKeyed()) — never read by today's - // collectEngineActionDeclarations, which is exactly the point being measured. - _storeKey: `orphan_action_${i}`, -})); - -// A SEPARATE control: a NAMED standalone orphan (today's code already admits -// this one) — establishes that the harness's zero count for the nameless -// batch is the identity gate, not "orphans never get admitted at all". -const namedStandaloneOrphan = { name: 'named_orphan_action', type: 'script' }; - -const loadStandaloneActions = async () => [...namelessStandaloneOrphans, namedStandaloneOrphan]; - -const { warnings, logger } = silentLogger(); - -// Exactly as ObjectQLPlugin.runGovernanceInventory calls it -// (`packages/objectql/src/plugin.ts#runGovernanceInventory`), -// with an empty registered-handler set and no object-embedded actions/registry -// rung, so the ENTIRE declaration set comes from `loadStandaloneActions`. -await runActionGovernanceInventory({ - registered: [], - objects: [], - loadStandaloneActions, - lookupRegistryAction: () => undefined, - logger, -}); - -const unboundWarning = warnings.find((w) => w.m.includes('declared script actions with NO handler')); -const before = { - namelessOrphansSubmitted: NAMELESS_ORPHAN_COUNT, - namedOrphanSubmitted: 1, - unboundDeclarationsReported: unboundWarning ? unboundWarning.meta.count : 0, - unboundDeclarationsList: unboundWarning ? unboundWarning.meta.actions : [], -}; - -// Direct, dependency-free confirmation of the SAME fact at the function level -// (bypasses the warn-log parsing above as a second, independent read): -const declarations = await collectEngineActionDeclarations([], loadStandaloneActions); -const reconciled = reconcileActionRegistrations([], declarations); - -console.error('--- #14423 unboundDeclarations BEFORE count: human summary ---'); -console.error(JSON.stringify({ - ...before, - declarationsAdmitted: declarations.length, - declarationsAdmittedNames: declarations.map((d) => d.action?.name ?? ''), - reconciledUnboundDeclarations: reconciled.unboundDeclarations, -}, null, 2)); -console.log(JSON.stringify({ before, declarationsAdmitted: declarations.length, reconciledUnboundDeclarations: reconciled.unboundDeclarations })); diff --git a/scripts/audits/14744-before-update-per-row-value-probe.mjs b/scripts/audits/14744-before-update-per-row-value-probe.mjs deleted file mode 100644 index dd32e77ecd8..00000000000 --- a/scripts/audits/14744-before-update-per-row-value-probe.mjs +++ /dev/null @@ -1,382 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#14744] Census instrument B — the RUNTIME behavioural probe, and the - * cross-check on instrument A's static predicate. - * - * Instrument A reasons about handler source. This one runs the REAL engine - * (`packages/objectql/src/engine.ts`) against a stub driver, dispatches REAL - * production handlers per row of a genuine `multi: true` update, and reads what - * actually reaches `driver.updateMany` — the one `SET` clause ADR-0058 - * Addendum II D3 gives N rows. Where a subject is a replica rather than the - * shipped handler it says so in its own `real` flag; the replica convention - * (and the audit stamp as the thing replicated) is the pin suite's own — - * `multi-update-hook-key-divergence.test.ts` §3 uses `perRowClockStamp()` - * rather than booting the plugin. - * - * ## How a subject is classified, and why it takes TWO scenarios - * - * Each subject runs twice over two rows: - * - * - **divergent** — the rows DISAGREE on the pre-image field the handler - * reads; - * - **uniform** — the rows AGREE on it. - * - * Two scenarios are needed because one cannot separate the residue from the - * clock. A single divergent run showing "same keys, different values" is - * equally consistent with (a) a value derived from the row's pre-image — the - * residue — and (b) a value that simply differs every time it is computed, like - * `sys_stamp_audit_update`'s `updated_at`. The uniform run settles it: with the - * pre-images equal, any remaining value difference is NOT attributable to the - * row, and the subject is nondeterministic rather than row-derived. That is the - * same discrimination #14099 made when it rejected a value comparison twice, - * arrived at from the other side. - * - * INSTANCE divergent: same key set, values differ - * AND uniform: values agree ⇒ the residue - * NONDETERMINISTIC uniform: values differ ⇒ the clock class - * CAUGHT_BY_14099 divergent: key sets differ ⇒ already refused - * ROW_INVARIANT divergent: same keys, same values - * NOT_A_PAYLOAD_WRITER - * - * ## The candidate guard, measured rather than modelled - * - * The instrument #14744 asks about — "refuse when a per-row dispatch READS the - * pre-image and WRITES the payload" — is evaluated here as an OBSERVER, never - * as an enforcement: each dispatch gets a read-recording `Proxy` over its - * context, so a `ctx.previous` / `ctx.input.id` read is recorded as it happens, - * and the payload is diffed around the call. `guardWouldFire` is therefore a - * measured property of the shipped handler's execution, not a reading of its - * source. ⛔ Nothing here is registered on, or changes, the engine's behaviour: - * the guard is not implemented, per the card's terminal scope. - */ - -import { writeFileSync } from 'node:fs'; -import { ObjectQL } from '../../packages/objectql/src/engine.ts'; -import { MultiUpdateHookKeyDivergenceError } from '../../packages/objectql/src/multi-update-hook-key-divergence.ts'; -import { bindRuleProvenanceStamp } from '../../packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts'; -import { bindWebhookProvenanceStamp } from '../../packages/plugins/plugin-webhooks/src/webhook-provenance.ts'; -import taskHookImported from '../../examples/app-todo/src/objects/task.hook.ts'; -// tsx's interop can hand back the module namespace rather than the default -// binding; resolve it by SHAPE so the probe fails loudly if neither carries a -// handler, instead of dispatching nothing and scoring the hook as clean. -const taskHook = typeof taskHookImported?.handler === 'function' - ? taskHookImported - : typeof taskHookImported?.default?.handler === 'function' ? taskHookImported.default : null; -if (!taskHook) throw new Error('probe: could not resolve examples/app-todo task hook handler'); - -const silentLogger = { debug() {}, info() {}, warn() {}, error() {} }; -const field = (name) => ({ name, label: name, type: 'text' }); - -function makeStubDriver() { - const store = new Map(); - const matches = (row, where) => { - if (!where || typeof where !== 'object') return true; - for (const [k, v] of Object.entries(where)) { - if (k.startsWith('$')) continue; - if (v && typeof v === 'object' && Array.isArray(v.$in)) { - if (!v.$in.some((x) => x === row[k])) return false; - continue; - } - const expected = v && typeof v === 'object' && '$eq' in v ? v.$eq : v; - if ((row[k] ?? null) !== (expected ?? null)) return false; - } - return true; - }; - const d = { - name: 'memory', version: '0.0.0', supports: {}, store, - updateCalls: 0, updateManyPayloads: [], - async connect() {}, async disconnect() {}, async checkHealth() { return true; }, - async execute() { return null; }, async syncSchema() {}, - async find(_o, ast, opts) { - const rows = [...store.values()].filter((r) => matches(r, ast?.where)); - const limit = typeof ast?.limit === 'number' ? ast.limit - : typeof opts?.limit === 'number' ? opts.limit : undefined; - return typeof limit === 'number' ? rows.slice(0, limit) : rows; - }, - async findOne(_o, ast) { for (const r of store.values()) if (matches(r, ast?.where)) return r; return null; }, - async create(_o, data) { const id = data.id ?? `r_${store.size + 1}`; const row = { ...data, id }; store.set(id, row); return row; }, - async update(_o, id, data) { d.updateCalls += 1; const cur = store.get(id); if (!cur) return null; const u = { ...cur, ...data, id }; store.set(id, u); return u; }, - async updateMany(_o, ast, data) { - d.updateManyPayloads.push({ ...data }); - const rows = [...store.values()].filter((r) => matches(r, ast?.where)); - for (const r of rows) store.set(r.id, { ...r, ...data, id: r.id }); - return rows.length; - }, - async delete(_o, id) { return store.delete(id); }, - async deleteMany(_o, ast) { const rows = [...store.values()].filter((r) => matches(r, ast?.where)); for (const r of rows) store.delete(r.id); return rows.length; }, - async count() { return store.size; }, - async bulkCreate(_o, rows) { return Promise.all(rows.map((r) => d.create(_o, r))); }, - async bulkUpdate() { return []; }, async bulkDelete() {}, - async upsert(_o, data) { return d.create(_o, data); }, - async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, - async commit() {}, async rollback() {}, - }; - return d; -} - -/** - * Wrap a handler so each per-row dispatch reports what it READ (pre-image) and - * what it WROTE (payload keys and values), without altering either. - */ -function observed(handler, log) { - return async (ctx) => { - const rec = { preImageRead: new Set(), assigned: {} }; - const data = ctx?.input?.data; - const proxy = new Proxy(ctx, { - get(t, p) { - if (p === 'previous' || p === 'previousRecord' || p === 'record') { - if (t[p] !== undefined) rec.preImageRead.add(`ctx.${String(p)}`); - return t[p]; - } - if (p === 'input') { - const input = t[p]; - if (!input || typeof input !== 'object') return input; - return new Proxy(input, { - get(it, ip) { - if (ip === 'id' || ip === 'ids') { if (it[ip] !== undefined) rec.preImageRead.add(`ctx.input.${String(ip)}`); } - // ⚠️ The payload is handed back behind a WRITE-RECORDING proxy, and - // what it records is ASSIGNMENT, not value change. Recording a - // change instead is a measurement artifact that silently rewrites - // the verdict: the payload is ONE object shared by every row's - // dispatch (D3), so the second row assigning the SAME value it - // found there produces no diff, and a change-based observer scores - // that row as "wrote nothing" — turning a row-invariant handler - // into a fabricated key-set divergence. Measured here on the - // pinyin replica before the fix. Assignment is also exactly what - // #14099's own recorder counts ("the set of payload keys the hook - // chain assigned"), so this keeps the two instruments comparable. - if (ip === 'data' && it[ip] && typeof it[ip] === 'object') { - const real = it[ip]; - return new Proxy(real, { - set(dt, dp, dv) { rec.assigned[String(dp)] = dv; dt[dp] = dv; return true; }, - deleteProperty(dt, dp) { rec.assigned[String(dp)] = '(deleted)'; delete dt[dp]; return true; }, - defineProperty(dt, dp, desc) { - if ('value' in desc) rec.assigned[String(dp)] = desc.value; - Object.defineProperty(dt, dp, desc); return true; - }, - }); - } - return it[ip]; - }, - set(it, ip, v) { it[ip] = v; return true; }, - }); - } - return t[p]; - }, - }); - try { - await handler(proxy); - } finally { - log.push({ - rowId: ctx?.input?.id ?? null, - preImageRead: [...rec.preImageRead], - writtenKeys: Object.keys(rec.assigned).sort(), - writtenValues: rec.assigned, - payloadAfter: data && typeof data === 'object' ? { ...data } : null, - }); - } - }; -} - -async function runScenario(subject, scenario) { - const engine = new ObjectQL(); - const driver = makeStubDriver(); - engine.registerDriver(driver, true); - await engine.init(); - engine.registry.registerObject({ - name: subject.object, label: subject.object, - fields: Object.fromEntries([['id', { ...field('id'), primaryKey: true }], - ...subject.fields.map((f) => [f, field(f)])]), - }); - - const log = []; - // A stub of the MinimalEngine face the provenance binders expect, forwarding - // registration to the real engine with the observer in between. - const bindTarget = { - registerHook: (event, handler, options) => engine.registerHook(event, observed(handler, log), options), - unregisterHooksByPackage: (pkg) => engine.unregisterHooksByPackage?.(pkg) ?? 0, - find: async (object, opts) => driver.find(object, opts, opts), - update: async () => ({}), - registry: engine.registry, - }; - subject.bind(bindTarget, engine, log); - - await engine.insert(subject.object, scenario.rows); - driver.updateManyPayloads.length = 0; - log.length = 0; - - let refusedWith = null; - try { - await engine.update(subject.object, { ...subject.payload }, { - multi: true, where: { id: { $in: scenario.rows.map((r) => r.id) } }, - }); - } catch (err) { - // ⚠️ Read the envelope by FIELD, not only through `instanceof`. Under tsx - // this probe's import of the divergence module and the engine's own can be - // two module instances, so `instanceof` is false against a genuine refusal; - // the ADR-0112 `code` is the identity that survives that, which is exactly - // the reason the code is a registered one (see the module's own note). - refusedWith = { - code: err?.code ?? null, status: err?.status ?? null, - keys: err?.keys ?? null, object: err?.object ?? null, rows: err?.rows ?? null, - name: err?.name, instanceofModule: err instanceof MultiUpdateHookKeyDivergenceError, - message: String(err?.message ?? err).slice(0, 200), - }; - } - return { - refusedWith, - dispatches: log, - updateManyPayloads: driver.updateManyPayloads.map((p) => ({ ...p })), - stored: [...driver.store.values()].sort((a, b) => String(a.id).localeCompare(String(b.id))), - }; -} - -const sameKeys = (a, b) => JSON.stringify(a) === JSON.stringify(b); -const valuesAgree = (a, b) => JSON.stringify(a) === JSON.stringify(b); - -function classify(divergent, uniform) { - const writers = (r) => r.dispatches.filter((d) => d.writtenKeys.length > 0); - const guardFires = [...divergent.dispatches, ...uniform.dispatches] - .some((d) => d.preImageRead.length > 0 && d.writtenKeys.length > 0); - - if (divergent.refusedWith?.code === 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE') { - return { verdict: 'CAUGHT_BY_14099', guardFires }; - } - const dWriters = writers(divergent); - if (dWriters.length === 0) return { verdict: 'NOT_A_PAYLOAD_WRITER', guardFires }; - - const uWriters = writers(uniform); - if (uWriters.length >= 2 && sameKeys(uWriters[0].writtenKeys, uWriters[1].writtenKeys) - && !valuesAgree(uWriters[0].writtenValues, uWriters[1].writtenValues)) { - return { verdict: 'NONDETERMINISTIC', guardFires }; - } - if (dWriters.length >= 2) { - if (!sameKeys(dWriters[0].writtenKeys, dWriters[1].writtenKeys)) { - return { verdict: 'KEY_SET_DIVERGENCE_UNREFUSED', guardFires }; - } - if (!valuesAgree(dWriters[0].writtenValues, dWriters[1].writtenValues)) { - return { verdict: 'INSTANCE', guardFires }; - } - } - if (dWriters.length === 1 && divergent.dispatches.length > 1) { - return { verdict: 'CAUGHT_BY_14099', guardFires }; - } - return { verdict: 'ROW_INVARIANT', guardFires }; -} - -/* ── Subjects ───────────────────────────────────────────────────────────── */ - -const SUBJECTS = [ - { - id: 'POSITIVE-CONTROL: the card\'s pinned residue', - real: false, note: 'The exact handler pinned in multi-update-hook-key-divergence.test.ts §4.', - object: 'task', fields: ['status', 'priority', 'title'], - payload: { title: 'renamed' }, - bind: (t) => t.registerHook('beforeUpdate', (ctx) => { - const prev = ctx.previous; - ctx.input.data.priority = prev?.status === 'blocked' ? 'high' : 'low'; - }, {}), - divergent: [{ id: 'a', status: 'blocked', priority: 'x', title: 't' }, { id: 'b', status: 'todo', priority: 'x', title: 't' }], - uniform: [{ id: 'a', status: 'todo', priority: 'x', title: 't' }, { id: 'b', status: 'todo', priority: 'x', title: 't' }], - }, - { - id: 'REPLICA: sys_stamp_audit_update (clock inside the per-record stamp)', - real: false, note: 'Shape of objectql plugin.ts:1137, replicated as the pin suite §3 does.', - object: 'task', fields: ['status', 'updated_at', 'title'], - payload: { title: 'renamed' }, - bind: (t) => { let tick = 0; t.registerHook('beforeUpdate', (ctx) => { - tick += 1; - ctx.input.data.updated_at = `2026-09-04T10:00:00.00${tick}Z`; - }, {}); }, - divergent: [{ id: 'a', status: 'blocked', updated_at: 'old', title: 't' }, { id: 'b', status: 'todo', updated_at: 'old', title: 't' }], - uniform: [{ id: 'a', status: 'todo', updated_at: 'old', title: 't' }, { id: 'b', status: 'todo', updated_at: 'old', title: 't' }], - }, - { - id: 'REAL: examples/app-todo task_logic', - real: true, note: 'The shipped example hook, imported and dispatched unmodified.', - object: 'todo_task', fields: ['status', 'completed_date', 'subject', 'priority'], - payload: { status: 'completed' }, - bind: (t) => t.registerHook('beforeUpdate', (ctx) => taskHook.handler(ctx), {}), - divergent: [{ id: 'a', status: 'in_progress', completed_date: null, subject: 's', priority: 'normal' }, - { id: 'b', status: 'completed', completed_date: '2026-01-01', subject: 's', priority: 'normal' }], - uniform: [{ id: 'a', status: 'in_progress', completed_date: null, subject: 's', priority: 'normal' }, - { id: 'b', status: 'in_progress', completed_date: null, subject: 's', priority: 'normal' }], - }, - { - id: 'REAL: plugin-sharing rule provenance stamp', - real: true, note: 'bindRuleProvenanceStamp, unmodified.', - object: 'sys_sharing_rule', fields: ['managed_by', 'customized', 'label'], - payload: { label: 'edited' }, - bind: (t) => bindRuleProvenanceStamp(t, silentLogger), - divergent: [{ id: 'a', managed_by: 'package', customized: false, label: 'l' }, - { id: 'b', managed_by: 'user', customized: false, label: 'l' }], - uniform: [{ id: 'a', managed_by: 'package', customized: false, label: 'l' }, - { id: 'b', managed_by: 'package', customized: false, label: 'l' }], - }, - { - id: 'REAL: plugin-webhooks provenance stamp', - real: true, note: 'bindWebhookProvenanceStamp, unmodified.', - object: 'sys_webhook', fields: ['managed_by', 'customized', 'label'], - payload: { label: 'edited' }, - bind: (t) => bindWebhookProvenanceStamp(t, silentLogger), - divergent: [{ id: 'a', managed_by: 'package', customized: false, label: 'l' }, - { id: 'b', managed_by: 'user', customized: false, label: 'l' }], - uniform: [{ id: 'a', managed_by: 'package', customized: false, label: 'l' }, - { id: 'b', managed_by: 'package', customized: false, label: 'l' }], - }, - { - id: 'REPLICA: pinyin companion projection (value from the PAYLOAD)', - real: false, note: 'Shape of plugin-pinyin-search companion-projection.ts:97.', - object: 'task', fields: ['title', '__search'], - payload: { title: 'Zhang San' }, - bind: (t) => t.registerHook('beforeUpdate', (ctx) => { - const data = ctx.input.data; - if ('title' in data) data.__search = String(data.title).toLowerCase(); - }, {}), - divergent: [{ id: 'a', title: 'A', __search: 'a' }, { id: 'b', title: 'B', __search: 'b' }], - uniform: [{ id: 'a', title: 'A', __search: 'a' }, { id: 'b', title: 'A', __search: 'a' }], - }, - { - id: 'NEGATIVE-CONTROL: reads the pre-image, never writes', - real: false, note: 'A pure guard — the guard predicate must NOT fire on it.', - object: 'task', fields: ['status', 'title'], - payload: { title: 'renamed' }, - bind: (t) => t.registerHook('beforeUpdate', (ctx) => { void ctx.previous?.status; }, {}), - divergent: [{ id: 'a', status: 'blocked', title: 't' }, { id: 'b', status: 'todo', title: 't' }], - uniform: [{ id: 'a', status: 'todo', title: 't' }, { id: 'b', status: 'todo', title: 't' }], - }, -]; - -const results = []; -for (const s of SUBJECTS) { - let divergent, uniform, error = null; - try { - divergent = await runScenario(s, { rows: s.divergent }); - uniform = await runScenario(s, { rows: s.uniform }); - } catch (err) { - error = String(err?.stack ?? err).slice(0, 400); - } - const cls = error ? { verdict: 'NOT_EVALUABLE', guardFires: null } : classify(divergent, uniform); - results.push({ subject: s.id, real: s.real, note: s.note, ...cls, error, divergent, uniform }); -} - -const out = { - generatedAt: new Date().toISOString(), - base: process.env.CENSUS_BASE ?? null, - verdicts: Object.fromEntries(results.map((r) => [r.subject, { verdict: r.verdict, guardFires: r.guardFires }])), - instanceCount: results.filter((r) => r.verdict === 'INSTANCE').length, - guardFiresCount: results.filter((r) => r.guardFires).length, - results, -}; -// ⚠️ The engine and the registry both log to stdout on boot, so the report is -// written to a file rather than piped: a JSON document interleaved with engine -// INFO lines parses as nothing, and a caller reading exit codes would never -// notice. `--out` is required for that reason. -const outIdx = process.argv.indexOf('--out'); -if (outIdx === -1 || !process.argv[outIdx + 1]) { - console.error('usage: tsx 14744-before-update-per-row-value-probe.mjs --out '); - process.exit(2); -} -writeFileSync(process.argv[outIdx + 1], JSON.stringify(out, null, 2)); -console.error(`probe: ${results.length} subjects · INSTANCE=${out.instanceCount} · guardFires=${out.guardFiresCount} · written to ${process.argv[outIdx + 1]}`); diff --git a/scripts/bench/permission-bench.mts b/scripts/bench/permission-bench.mts deleted file mode 100644 index ececfa8f8f2..00000000000 --- a/scripts/bench/permission-bench.mts +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -/** - * [ADR-0090 P4 / Addendum] Permission-pipeline benchmark — single-org scale. - * - * Target topology per the ADR-0090 Addendum recalibration: group deployments - * are multi-org (organization_id prunes first), so the meaningful gate is a - * SINGLE org at ≈10k users × 1M rows — not the retired 100k-user mega-unit. - * - * Measures, against the in-memory driver (pure pipeline cost, no I/O): - * 1. permission-set resolution (resolvePermissionSets path) - * 2. object CRUD evaluation (PermissionEvaluator) - * 3. read-filter composition (getReadFilter: RLS + scope) - * and asserts the O()-shape property that matters: per-request work must not - * scale with the USER population (pre-resolved sets, no fan-out queries). - * - * Run manually / nightly (not a CI gate — wall-clock asserts are flaky): - * npx tsx scripts/bench/permission-bench.mts [users] [rows] - * Defaults: 10_000 users, 100_000 rows (pass 1_000_000 for the full gate on - * a machine with ≥8 GB free). - */ - -import { PermissionEvaluator } from '../../packages/plugins/plugin-security/dist/index.js'; -import { PermissionSetSchema } from '../../packages/spec/dist/security/index.js'; - -const USERS = Number(process.argv[2] ?? 10_000); -const ROWS = Number(process.argv[3] ?? 100_000); - -const sales = PermissionSetSchema.parse({ - name: 'sales_user', - objects: { bench_deal: { allowRead: true, allowCreate: true, allowEdit: true, readScope: 'unit' } }, -}); -const support = PermissionSetSchema.parse({ - name: 'support_user', - objects: { bench_ticket: { allowRead: true, allowCreate: true } }, -}); -const baseline = PermissionSetSchema.parse({ - name: 'member_default', - objects: {}, - rowLevelSecurity: [ - { name: 'owner_only_writes', object: '*', operation: 'update', using: 'owner_id == current_user.id' }, - ], -}); - -function hrtimeMs(): number { - return Number(process.hrtime.bigint()) / 1e6; -} - -async function main() { - console.log(`permission-bench: ${USERS.toLocaleString()} users × ${ROWS.toLocaleString()} rows (single org)`); - const evaluator = new PermissionEvaluator(); - - // 1. CRUD evaluation throughput — the per-request hot path. - const sets = [sales, support, baseline] as any[]; - const N = 200_000; - let allowed = 0; - const t0 = hrtimeMs(); - for (let i = 0; i < N; i++) { - if (evaluator.checkObjectPermission('find', 'bench_deal', sets, { isPrivate: true })) allowed++; - } - const t1 = hrtimeMs(); - console.log(` checkObjectPermission: ${N.toLocaleString()} evals in ${(t1 - t0).toFixed(0)}ms ` + - `(${((t1 - t0) / N * 1000).toFixed(2)}µs/eval, ${allowed === N ? 'all allowed ✓' : 'MISMATCH ✗'})`); - - // 2. Depth resolution — must be O(sets), not O(users). - const t2 = hrtimeMs(); - for (let i = 0; i < N; i++) evaluator.getEffectiveScope('read', 'bench_deal', sets, { isPrivate: true }); - const t3 = hrtimeMs(); - console.log(` getEffectiveScope: ${N.toLocaleString()} evals in ${(t3 - t2).toFixed(0)}ms (${((t3 - t2) / N * 1000).toFixed(2)}µs/eval)`); - - // 3. Owner-set membership simulation: the unit-depth IN-set for a 200-user - // business unit inside a USERS-sized org — the pre-resolved-membership - // contract (ADR-0055 no-subquery). Cost must track UNIT size, not org size. - const unit = Array.from({ length: 200 }, (_, i) => `u${i}`); - const rows = Array.from({ length: ROWS }, (_, i) => ({ id: `r${i}`, owner_id: `u${i % USERS}` })); - const t4 = hrtimeMs(); - const members = new Set(unit); - const visible = rows.filter((r) => members.has(r.owner_id)).length; - const t5 = hrtimeMs(); - console.log(` unit IN-set filter: ${ROWS.toLocaleString()} rows scanned in ${(t5 - t4).toFixed(0)}ms → ${visible.toLocaleString()} visible ` + - `(driver pushes this down as owner_id IN (…) in SQL)`); - - // O()-shape assertion: evaluation cost is independent of USERS — resolve a - // context for 1 user and for the "whole org" (same 3 sets) and require the - // per-eval cost to stay within 3× (generous; catches accidental O(users) loops). - const perEvalSmall = (t1 - t0) / N; - const t6 = hrtimeMs(); - for (let i = 0; i < N; i++) evaluator.checkObjectPermission('find', 'bench_deal', sets, { isPrivate: true }); - const t7 = hrtimeMs(); - const perEvalAgain = (t7 - t6) / N; - const ratio = perEvalAgain / perEvalSmall; - if (ratio > 3) { - console.error(` ✗ O()-shape violation: per-eval cost drifted ${ratio.toFixed(1)}× across identical workloads`); - process.exit(1); - } - console.log(` ✓ per-request cost independent of population (${ratio.toFixed(2)}× drift across runs)`); - console.log('done — full 1M-row gate: npx tsx scripts/bench/permission-bench.mts 10000 1000000'); -} - -main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/bench/runtime-publish-gate.bench.mts b/scripts/bench/runtime-publish-gate.bench.mts deleted file mode 100644 index 85cbdc2e365..00000000000 --- a/scripts/bench/runtime-publish-gate.bench.mts +++ /dev/null @@ -1,612 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -/** - * Runtime publish-gate benchmark (#9851) — what one `active` metadata publish - * costs at the shipped gate, as a function of the tenant's stored objects. - * - * ── The three modes ───────────────────────────────────────────────────────── - * - * --mode total (default) the whole-gate cost table. #9851's reading. - * --mode per-rule (#9905) the same total, ATTRIBUTED across the rules the - * door dispatches. - * --mode closure (#9905) the whole-gate cost when the `objects` collection - * is narrowed to the written item's reference closure. - * --mode package (#9612) the whole-gate cost when `objects` is narrowed to - * the written item's PACKAGE closure, by the SHIPPED - * deriver — plus the differential-verdict check and two - * positive controls. Unlike `--mode closure` this is not a - * hypothetical: it calls the same - * `narrowObjectsToPackageClosure` the gate calls, on a - * PACKAGED seed, so the number describes shipped behaviour. - * - * ⛔ `--mode closure` measures a HYPOTHETICAL. It narrows what the rules are - * handed HERE, in this script, and changes nothing about the shipped gate — - * which always receives the whole collection. Its closure deriver is a - * deliberately generous over-approximation written to bound the saving, NOT a - * proposed implementation: how (or whether) the shipped gate should scope its - * input is an open maintainer decision (#9612 / #9613), and one that this - * measurement exists to inform rather than to pre-empt. - * - * ── Why this file exists ──────────────────────────────────────────────────── - * - * Two published measurements of THIS operation disagreed by 16-25x with a - * non-constant ratio, and neither could be re-run: both came from throwaway - * harnesses that no longer exist. #9851 reconciled them — the disagreement is - * entirely the SHAPE OF THE SEEDED `objects` COLLECTION, not the span, not the - * warmup, and not a code change between the two refs — and left this file so - * the next reading is reproducible instead of re-litigated. - * - * npx tsx scripts/bench/runtime-publish-gate.bench.mts - * npx tsx scripts/bench/runtime-publish-gate.bench.mts --type object - * npx tsx scripts/bench/runtime-publish-gate.bench.mts --objects 21,105,420 --iterations 50 - * - * Requires `pnpm --filter '@objectstack/lint^...' build && pnpm --filter - * @objectstack/lint build` first: the gate is imported straight from - * `packages/lint/dist/runtime.js` — the built artifact the `./runtime` export - * points at, reached by path because this script is outside the workspace - * graph — so an unbuilt or stale `dist` reports the cost of the OLD registry - * (this is the `ablation-dist-preflight` hazard; the run prints the rules it - * actually dispatched, so a stale build shows up as a changed rule list rather - * than as a silently wrong number). - * - * ⚠️ Not a CI gate. Wall-clock numbers are machine-dependent and this asserts - * no threshold; it prints, and the operator compares. - * - * ── THE TIMED SPAN — inside vs outside ────────────────────────────────────── - * - * INSIDE the timed region: exactly one call to `runRuntimeAuthoringRules()` — - * the whole shipped gate for one write. That is `buildRuntimeWriteSnapshots` - * (baseline + candidate) plus the differential TWO passes of every rule that - * declares the written type in `runtimeTypes`, plus the fingerprint set-diff. - * - * OUTSIDE: seeding and cloning the stack, module load, and everything - * `saveMetaItem` wraps around the gate — the per-type Zod `safeParse`, - * persistence, hooks, audit, the HTTP envelope. - * - * ⭐ That exclusion is measured, not assumed to be small: the gate call alone - * reproduces the whole-`saveMetaItem` figure published as #9851's Reading A to - * within ~8%, so the gate IS substantially the whole per-publish bill and the - * span is NOT what separated the two published readings. - * - * ── WARMUP POLICY ─────────────────────────────────────────────────────────── - * - * `--warmup` iterations (default 5) run and are discarded, then `--iterations` - * (default 30) are timed and the MEDIAN is reported, with min/max so a noisy - * machine is visible. ⭐ Measured on this workload, warm and cold agree to - * within noise (a cold 3-iteration median lands inside the warm min/max band at - * both sizes), because a single iteration is already tens of milliseconds of - * the same loop — long enough for the JIT to settle mid-iteration. So warmup is - * kept for hygiene, NOT because it moves the number: it is not the explanation - * for any two harnesses disagreeing here. - * - * ── THE SEEDED STACK — the parameter that actually drives the cost ─────────── - * - * The gate's context is the bounded 4-collection `RuntimeStackContext` - * (`objects` / `permissions` / `books` / `datasets`); only `objects` is varied - * here, because that is the collection both published readings varied and the - * one a tenant grows. Two shapes ship, and running BOTH is the point: - * - * real — the shipped `examples/app-showcase` object declarations (22 of - * them), cloned under suffixed names up to N. Real authored surface: - * formula fields, validation rules, predicates, lookups — i.e. the - * expression-bearing keys `validateStackExpressions` walks. - * stub — synthetic 5-field objects carrying no expressions at all. - * - * ⭐ Same gate, same N, same warmup: `real` costs ~25x `stub`. **Per-publish - * cost is a function of the tenant's authored object SURFACE, not of its object - * COUNT** — count is only a proxy, and the proxy's constant is what the two - * published readings silently disagreed about. A number quoted as "X ms at N - * objects" without its stack shape is therefore not reproducible, and any - * acceptance threshold derived from one must name the shape it was set against. - * - * ⚠️ `real` imports the showcase app LIVE, so its absolute numbers drift as - * that app is edited. Deliberate — it is the only real, spec-parsed corpus in - * the repo, and a frozen copy would silently stop describing shipped metadata. - * Quote the ref you measured at. - * - * ── THE OBJECT DOOR, BOTH SIDES OF #4716 — MEASURED ON BOTH SHAPES (#9859) ── - * - * #9825's cost table for the object-door widening was published from the `stub` - * shape alone, so its `+0.31 / +3.25 ms` delta describes a stack in which the - * rules that judge validation rules and autonumber formats find nothing to - * judge. Both legs re-measured here at `17854cba0`, one machine, one session, - * median of 30 with warmup 5. The BEFORE leg is built from the PRE-WIDENING - * registry — `git restore --source=1408ae337^ -- packages/lint/src/authoring- - * rules.ts`, rebuild `@objectstack/lint`, then read `rules dispatched (2)` off - * the run's own header before believing any number (`ablation-dist-preflight` - * both ways: marker `P2 (#4463): judges an object/field declaration` PRESENT in - * `dist` on the BEFORE leg, ABSENT again after restoring): - * - * N=420, object write BEFORE (2 rules) AFTER (7 rules) delta - * stub 3.6-4.4 ms 8.5-8.6 ms ~ +4.6 ms - * real (showcase decls) 25.5-29.2 ms 282.8-304.4 ms ~ +265 ms - * - * N=21, object write BEFORE (2 rules) AFTER (7 rules) delta - * stub 0.19-0.21 ms 0.41-0.45 ms ~ +0.23 ms - * real (showcase decls) 1.20-1.32 ms 16.7-18.9 ms ~ +16.5 ms - * - * ⛔ The real-shape delta is NOT the stub delta scaled by the totals ratio, and - * this is the measurement that shows it: at N=420 the totals ratio is ~34x, so - * `+3.25 x 34` would predict ~+110 ms where the measured delta is ~+265 ms. A - * ratio measured on TOTALS does not transfer to a DELTA between two rule sets, - * because the sets walk different keys. `--mode per-rule` says exactly why — - * on the real shape at N=420 `validateRuleCompilability` alone is ~233 ms (82% - * of the whole gate) while on the stub shape it is ~0.04 ms (0.5%), the stub - * carrying no validation rules for it to judge. The two rules that were already - * at the door cost the same on both legs (~23-25 ms, real, N=420), as they must. - */ - -import { - runRuntimeAuthoringRules, - runtimeAuthoringRulesFor, - buildRuntimeWriteSnapshots, - narrowObjectsToPackageClosure, -} from '../../packages/lint/dist/runtime.js'; -import * as showcaseObjects from '../../examples/app-showcase/src/data/objects/index.js'; -import { allFlows } from '../../examples/app-showcase/src/automation/flows/index.js'; - -// Ambient `process` / `console`, declared to exactly the members used below. -// `scripts/**` is inside the ROOT tsconfig's program, and that config carries -// `lib: ['ES2020']` with no `types` — so Node's globals are absent and every -// use of them is a root-ledger tsc error (measured: 19 from this file alone, -// which is how it first landed). tsx provides the real objects at run time. -// Same shape and same reason as the ambient `process` in -// `examples/app-showcase/objectstack.config.ts`: narrow the declaration to -// what is actually called rather than widening the root's type surface, so -// this file pays its own way through `pnpm check:type-check-debt` instead of -// pushing a shrink-only ratchet back up. -declare const process: { - argv: string[]; - hrtime: { bigint(): bigint }; -}; -declare const console: { log(...args: unknown[]): void }; - -type AnyRec = Record; - -function arg(name: string, fallback: string): string { - const hit = process.argv.find((a) => a.startsWith(`--${name}=`)); - if (hit) return hit.slice(name.length + 3); - const i = process.argv.indexOf(`--${name}`); - return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback; -} - -const WRITE_TYPE = arg('type', 'flow'); -const SIZES = arg('objects', '21,420').split(',').map((s) => Number(s.trim())).filter(Boolean); -const ITERATIONS = Number(arg('iterations', '30')); -const WARMUP = Number(arg('warmup', '5')); -const MODE = arg('mode', 'total'); - -/** The shipped showcase object declarations — the `real` seed's source corpus. */ -const SHOWCASE_OBJECTS = (Object.values(showcaseObjects) as AnyRec[]) - .filter((o) => o && typeof o.name === 'string'); - -/** A real shipped flow, used as the written item for a `flow` publish. */ -const SHOWCASE_FLOW = (allFlows as AnyRec[]).find((f) => f.name === 'showcase_task_completed') - ?? (allFlows as AnyRec[])[0]; - -/** A minimal clean object body, used as the written item for an `object` publish. */ -const WRITTEN_OBJECT: AnyRec = { - name: 'bench_written_object', - label: 'Bench Written Object', - sharingModel: 'ReadWrite', - fields: { name: { type: 'text', label: 'Name' } }, -}; - -/** - * N objects cloned from the shipped showcase corpus, names suffixed to stay unique. - * - * ⚠️ The clone is `JSON.parse(JSON.stringify(...))`, so any FUNCTION-valued key - * in a showcase declaration is dropped rather than copied. Harmless for what is - * measured here — the runtime gate receives `sys_metadata` bodies, which are - * JSON at rest, so a JSON round-trip is the shape the real door sees — but it - * does mean this seed is a lower bound on a declaration whose authored surface - * lives partly in functions. Stated because the file's honesty about its own - * seed is the thing that makes a number taken from it re-runnable. - */ -function seedReal(n: number): AnyRec[] { - const out: AnyRec[] = []; - for (let i = 0; out.length < n; i++) { - const src = SHOWCASE_OBJECTS[i % SHOWCASE_OBJECTS.length]!; - const clone = JSON.parse(JSON.stringify(src)) as AnyRec; - const generation = Math.floor(i / SHOWCASE_OBJECTS.length); - if (generation > 0) clone.name = `${src.name}_c${generation}`; - out.push(clone); - } - return out; -} - -/** N synthetic 5-field objects carrying no expressions. */ -function seedStub(n: number): AnyRec[] { - return Array.from({ length: n }, (_, i) => ({ - name: `bench_object_${i}`, - label: `Bench Object ${i}`, - fields: { - name: { type: 'text', label: 'Name' }, - amount: { type: 'number', label: 'Amount' }, - active: { type: 'boolean', label: 'Active' }, - due: { type: 'date', label: 'Due' }, - notes: { type: 'textarea', label: 'Notes' }, - }, - })); -} - -const SHAPES: Record AnyRec[]> = { real: seedReal, stub: seedStub }; - -const ms = () => Number(process.hrtime.bigint()) / 1e6; -const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]!; - -function run(objects: AnyRec[], item: AnyRec) { - return runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects } }); -} - -function measure(objects: AnyRec[], item: AnyRec) { - for (let i = 0; i < WARMUP; i++) run(objects, item); - const samples: number[] = []; - for (let i = 0; i < ITERATIONS; i++) { - const t0 = ms(); - run(objects, item); - samples.push(ms() - t0); - } - return { median: median(samples), min: Math.min(...samples), max: Math.max(...samples) }; -} - -const item = WRITE_TYPE === 'flow' ? SHOWCASE_FLOW : WRITTEN_OBJECT; - -// The rules actually dispatched, printed BEFORE any timing: a stale `dist/`, or -// a rule ablated off the runtime surface, changes this line — so a number that -// moved for a registry reason can never be read as a number that moved for a -// performance reason. -const dispatched = run(SHAPES.real!(1), item).rulesRun; -console.log(`runtime publish gate — write type '${WRITE_TYPE}'`); -console.log(` rules dispatched (${dispatched.length}): ${dispatched.join(', ') || ''}`); -console.log(` timed span: one runRuntimeAuthoringRules() call (differential two-pass)`); -console.log(` warmup ${WARMUP} discarded, ${ITERATIONS} timed, median reported`); -if (dispatched.length === 0) { - console.log(` ⚠️ no rule gates '${WRITE_TYPE}' at the runtime surface — every number below is gate overhead only`); -} - -/** Median wall-clock of `fn`, same warmup/iteration policy as {@link measure}. */ -function timeMedian(fn: () => void): number { - for (let i = 0; i < WARMUP; i++) fn(); - const samples: number[] = []; - for (let i = 0; i < ITERATIONS; i++) { - const t0 = ms(); - fn(); - samples.push(ms() - t0); - } - return median(samples); -} - -/** #9851's reading: the whole-gate cost table, both shapes, every N. */ -function totalMode() { - console.log(''); - console.log(' shape N objects median min max'); - const table: Record> = {}; - for (const shape of Object.keys(SHAPES)) { - table[shape] = {}; - for (const n of SIZES) { - const r = measure(SHAPES[shape]!(n), item); - table[shape]![n] = r.median; - console.log( - ` ${shape.padEnd(6)} ${String(n).padStart(9)} ${r.median.toFixed(2).padStart(7)} ms ` - + `${r.min.toFixed(2).padStart(7)} ${r.max.toFixed(2).padStart(7)}`, - ); - } - } - - console.log(''); - for (const n of SIZES) { - const real = table.real![n]!; - const stub = table.stub![n]!; - console.log(` N=${n}: real / stub = ${(real / stub).toFixed(1)}x — same gate, same N, different authored surface`); - } - console.log(''); - console.log(' ⇒ per-publish cost tracks authored object SURFACE, not object COUNT.'); - console.log(' Quote the stack shape with any number taken from this gate (#9851).'); -} - -/** - * #9905 deliverable 2 — the same total, attributed across the dispatched rules. - * - * Each rule is timed over BOTH gate passes (baseline + candidate), because that - * is what one publish actually costs it, and the snapshots come from the gate's - * own exported {@link buildRuntimeWriteSnapshots} rather than a mirror — so the - * shares are attributed against the real construction. - * - * ⭐ The UNATTRIBUTED line is the anti-vacuity check and is printed even when it - * is uninteresting: per-rule shares that do not add up to the measured total - * mean the attribution is wrong, and that is the finding. Confirm the top share - * independently by ablating that rule off the door (`runtimeTypes`), rebuilding, - * and checking the total moves by roughly its share — `rules dispatched` above - * changes with it, so a stale `dist/` cannot masquerade as a saving. - */ -function perRuleMode() { - const rules = runtimeAuthoringRulesFor(WRITE_TYPE); - const ctx = { sduiManifest: undefined }; - for (const shape of Object.keys(SHAPES)) { - for (const n of SIZES) { - const objects = SHAPES[shape]!(n); - const snapshots = buildRuntimeWriteSnapshots({ type: WRITE_TYPE, item, context: { objects } }); - if (!snapshots) continue; - const total = timeMedian(() => { run(objects, item); }); - const per = rules - .map((r) => ({ - name: r.name, - ms: timeMedian(() => { - try { r.run(snapshots.baseline, ctx); } catch { /* the gate reports throws as findings */ } - try { r.run(snapshots.candidate, ctx); } catch { /* idem */ } - }), - })) - .sort((a, b) => b.ms - a.ms); - const sum = per.reduce((acc, p) => acc + p.ms, 0); - const pct = (x: number) => `${((x / total) * 100).toFixed(1).padStart(5)}%`; - console.log(''); - console.log(` shape=${shape} N=${n} — whole-gate total ${total.toFixed(2)} ms`); - for (const p of per) console.log(` ${p.name.padEnd(32)} ${p.ms.toFixed(2).padStart(8)} ms ${pct(p.ms)}`); - console.log(` ${'Σ attributed'.padEnd(32)} ${sum.toFixed(2).padStart(8)} ms ${pct(sum)}`); - console.log(` ${'unattributed (snapshot + diff)'.padEnd(32)} ${(total - sum).toFixed(2).padStart(8)} ms ${pct(total - sum)}`); - } - } -} - -/** Every string anywhere in a value — the closure seed scan, deliberately broad. */ -function allStrings(value: unknown, out: string[] = []): string[] { - if (typeof value === 'string') out.push(value); - else if (Array.isArray(value)) for (const v of value) allStrings(v, out); - else if (value && typeof value === 'object') for (const v of Object.values(value)) allStrings(v, out); - return out; -} - -/** - * The transitive reference closure of the written item over `objects`: every - * object NAMED anywhere in the item, expanded through the included objects' - * declared relational edges (`fields[].reference`) to a fixed point. - * - * Over-approximating on purpose — it seeds from any string that happens to equal - * an object name, so it can only ever be too LARGE. A saving measured against it - * is therefore a lower bound on what an exact closure would buy, which is the - * safe direction for a number that argues FOR narrowing. - */ -function referenceClosure(written: AnyRec, objects: AnyRec[]): AnyRec[] { - const byName = new Map(); - for (const o of objects) if (typeof o.name === 'string') byName.set(o.name, o); - const reached = new Set(allStrings(written).filter((s) => byName.has(s))); - const frontier = [...reached]; - while (frontier.length > 0) { - const owner = byName.get(frontier.pop()!)!; - for (const field of Object.values((owner.fields ?? {}) as AnyRec)) { - const ref = (field as AnyRec)?.reference; - for (const target of Array.isArray(ref) ? ref : [ref]) { - if (typeof target === 'string' && byName.has(target) && !reached.has(target)) { - reached.add(target); - frontier.push(target); - } - } - } - } - return [...reached].map((name) => byName.get(name)!); -} - -/** - * #9905 deliverable 1 — what NARROWING the objects collection would buy, timed. - * - * Prints `|closure| / N` (the size ratio, which is NOT the saving) next to the - * measured saving (which is), and re-runs the gate on the narrowed collection to - * confirm the differential VERDICT is unchanged — a saving that changes the - * verdict is not a saving, it is PR #7886's phantom-findings failure. - */ -function closureMode() { - const stable = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => - [...r.errors, ...r.advisories].map((f) => `${f.rule}|${f.where}|${f.path}|${f.message}`).sort().join('\n'); - for (const shape of Object.keys(SHAPES)) { - for (const n of SIZES) { - const objects = SHAPES[shape]!(n); - const closure = referenceClosure(item as AnyRec, objects); - const full = timeMedian(() => { run(objects, item); }); - const scoped = timeMedian(() => { run(closure, item); }); - const agree = stable(run(objects, item)) === stable(run(closure, item)); - console.log(''); - console.log(` shape=${shape} N=${n}: |closure| = ${closure.length} (closure/N = ${((closure.length / n) * 100).toFixed(1)}%)`); - console.log(` members: ${closure.map((o) => String(o.name)).join(', ') || ''}`); - console.log(` whole gate: full ${full.toFixed(2)} ms → closure ${scoped.toFixed(2)} ms — saving ${(((full - scoped) / full) * 100).toFixed(1)}%`); - console.log(` differential verdict unchanged: ${agree ? 'YES' : 'NO — narrowing changed the answer'}`); - } - } -} - - -// ───────────────────────────────────────────────────────────────────────────── -// #9612 — `--mode package`: what narrowing to the written item's PACKAGE -// closure buys, measured against the SHIPPED deriver rather than a hypothetical. -// ───────────────────────────────────────────────────────────────────────────── - -/** Objects per synthetic package in the packaged seed. */ -const PKG_SIZE = 22; -/** The package the written item belongs to. */ -const OWN_PKG = 'pkg_0'; -/** The one package `OWN_PKG` declares a dependency on. */ -const DEP_PKG = 'pkg_1'; -/** The platform's package — never declared as a dependency by anyone. */ -const PLATFORM_PKG = 'pkg_platform'; -/** A platform object the written item legitimately references. */ -const SYSTEM_OBJECT = 'sys_bench_ledger'; - -/** - * The seed, stamped with package provenance — the shape the ruling describes. - * - * A tenant that has grown to N objects is MANY packages, not one, so the seed - * distributes the corpus across `ceil(N / PKG_SIZE)` packages. It also carries - * ONE platform object (`isSystem`, `sys_` prefix, owned by a package nobody - * declares) so the "system objects are unconditionally in the closure" limb has - * something to be right or wrong about — control B below is what makes that a - * measurement rather than an assertion. - */ -function seedPackaged(shape: string, n: number): AnyRec[] { - const objects = SHAPES[shape]!(n).map((o, i) => ({ - ...o, - _packageId: `pkg_${Math.floor(i / PKG_SIZE)}`, - })); - const platform = JSON.parse(JSON.stringify(SHOWCASE_OBJECTS[0]!)) as AnyRec; - platform.name = SYSTEM_OBJECT; - platform.isSystem = true; - platform._packageId = PLATFORM_PKG; - return [...objects, platform]; -} - -/** `{ packageId, dependencies }` as the host resolves it from the registry. */ -const PACKAGE_SCOPE = { packageId: OWN_PKG, dependencies: [DEP_PKG] } as const; - -/** - * The written item, stamped into `OWN_PKG` and — for a flow — repointed at the - * platform object so control B has a reference that must survive the closure. - */ -function packagedWrittenItem(): AnyRec { - if (WRITE_TYPE !== 'flow') return { ...WRITTEN_OBJECT, _packageId: OWN_PKG }; - const flow = JSON.parse(JSON.stringify(SHOWCASE_FLOW)) as AnyRec; - const primary = referenceClosure(flow, SHAPES.real!(PKG_SIZE))[0]; - const primaryName = primary ? String(primary.name) : undefined; - const repointed = primaryName - ? (JSON.parse( - JSON.stringify(flow).split(JSON.stringify(primaryName).slice(1, -1)).join(SYSTEM_OBJECT), - ) as AnyRec) - : flow; - repointed._packageId = OWN_PKG; - return repointed; -} - -/** - * The item positive control A drives: one that really does reference objects in - * its OWN package, so dropping that package from the closure has something to - * break. For a flow that is the shipped showcase flow untouched; for an object - * write it is the same body the timing legs use, because the object door offers - * no cross-object reference to break — which is itself the measurement. - */ -function controlItemOwnPackage(): AnyRec { - if (WRITE_TYPE !== 'flow') return { ...WRITTEN_OBJECT, _packageId: OWN_PKG }; - const flow = JSON.parse(JSON.stringify(SHOWCASE_FLOW)) as AnyRec; - flow._packageId = OWN_PKG; - return flow; -} - -/** - * A finding's identity for verdict comparison — the semantic tuple, which - * since #10064 is the tuple AS EMITTED. - * - * The pre-#10064 reason for two readings is gone: the gate now keys a - * collection-resident finding's top-level collection entry by NAME - * (`objects.acme_invoice.sharingModel`), so narrowing the snapshot no longer - * moves anything in `path` and the raw tuple IS the semantic one. The - * differential comparison therefore reads the full - * `(rule, where, path, message)` tuple by default (the #10064 ruling). - * - * `verdictIndexInsensitive` survives as a CANARY, not as the default: the - * name-keying falls back to the positional spelling for an entry with no - * splice-safe name, and a positional top-level path CAN legitimately move - * under narrowing again. When the two readings disagree, the index-only - * difference is printed — hiding it would report a phantom as a verdict - * change (or vice versa) with nothing to diagnose it from. - */ -const stripIndices = (p: unknown) => String(p ?? '').replace(/\[\d+\]/g, '[i]'); -const verdict = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => - [...r.errors, ...r.advisories].map((f) => `${f.rule}|${f.where}|${f.path}|${f.message}`).sort(); -const verdictIndexInsensitive = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => - [...r.errors, ...r.advisories] - .map((f) => `${f.rule}|${f.where}|${stripIndices(f.path)}|${f.message}`) - .sort(); - -function diffCount(a: string[], b: string[]): { added: number; removed: number } { - const bs = new Set(b); - const as = new Set(a); - return { - added: b.filter((x) => !as.has(x)).length, - removed: a.filter((x) => !bs.has(x)).length, - }; -} - -function packageMode() { - const item = packagedWrittenItem(); - console.log(''); - console.log(` package closure — own '${OWN_PKG}' + declared dep '${DEP_PKG}' + platform/system + unpackaged`); - console.log(` seed: ${PKG_SIZE} objects per package, plus one '${SYSTEM_OBJECT}' owned by '${PLATFORM_PKG}' (declared by nobody)`); - - for (const shape of Object.keys(SHAPES)) { - for (const n of SIZES) { - const objects = seedPackaged(shape, n); - const closure = narrowObjectsToPackageClosure(objects, PACKAGE_SCOPE) as AnyRec[]; - - const runFull = () => runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects } }); - const runScoped = () => - runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects }, packageScope: PACKAGE_SCOPE }); - - const full = timeMedian(() => { runFull(); }); - const scoped = timeMedian(() => { runScoped(); }); - const fullV = runFull(); - const scopedV = runScoped(); - - console.log(''); - console.log( - ` shape=${shape} N=${objects.length}: |closure| = ${closure.length}` - + ` (closure/N = ${((closure.length / objects.length) * 100).toFixed(1)}%)`, - ); - console.log( - ` whole gate: full ${full.toFixed(2)} ms → package closure ${scoped.toFixed(2)} ms` - + ` — saving ${(((full - scoped) / full) * 100).toFixed(1)}%`, - ); - // [#10064] The semantic tuple is the default — and it is the tuple as - // emitted, path included, now that the wire path is name-keyed. The - // index-insensitive reading prints only when it disagrees (a positional - // fallback path moved), so an index-only artefact cannot silently pose - // as a verdict change. - const semD = diffCount(verdict(fullV), verdict(scopedV)); - const insD = diffCount(verdictIndexInsensitive(fullV), verdictIndexInsensitive(scopedV)); - console.log( - ` differential verdict: ${semD.added === 0 && semD.removed === 0 ? 'UNCHANGED' : `CHANGED (+${semD.added} / -${semD.removed})`}` - + (semD.added === insD.added && semD.removed === insD.removed - ? '' - : ` · index-insensitive reading disagrees: +${insD.added} / -${insD.removed} (a positional-fallback path moved)`), - ); - console.log(` findings: full ${fullV.errors.length}e/${fullV.advisories.length}a → scoped ${scopedV.errors.length}e/${scopedV.advisories.length}a`); - - // ── Positive control A — the closure loses the written package itself. - // - // Driven by {@link controlItemOwnPackage}, NOT by `item`: the timed item - // above was repointed at the platform object for control B's sake, so it - // no longer references its own package and would make this control - // vacuous BY CONSTRUCTION rather than by a property of the door. This - // control needs an item whose references live in `pkg_0`. - // - // If it prints a zero delta the verdict check above is not falsifiable on - // this door, so it is printed whatever it says. - const ctrlItem = controlItemOwnPackage(); - const ctrlAFull = runRuntimeAuthoringRules({ type: WRITE_TYPE, item: ctrlItem, context: { objects } }); - const ctrlA = objects.filter((o) => { - const owner = String(o._packageId ?? ''); - return owner === DEP_PKG || o.isSystem === true || String(o.name ?? '').startsWith('sys_'); - }); - const ctrlAV = runRuntimeAuthoringRules({ type: WRITE_TYPE, item: ctrlItem, context: { objects: ctrlA } }); - const ctrlAD = diffCount(verdict(ctrlAFull), verdict(ctrlAV)); - console.log( - ` positive control A (own package dropped, |set| = ${ctrlA.length}): ` - + `${ctrlAD.added === 0 && ctrlAD.removed === 0 ? '⚠️ NO DELTA — control is vacuous on this door' : `delta +${ctrlAD.added} / -${ctrlAD.removed}`}`, - ); - - // ── Positive control B — the card's named hazard: a closure that omits - // SYSTEM objects. The written item references `sys_bench_ledger`, which - // no package declares, so only the unconditional system limb keeps it. - const ctrlB = objects.filter((o) => { - const owner = String(o._packageId ?? ''); - return owner === OWN_PKG || owner === DEP_PKG; - }); - const ctrlBV = runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects: ctrlB } }); - const ctrlBD = diffCount(verdict(fullV), verdict(ctrlBV)); - console.log( - ` positive control B (system objects dropped, |set| = ${ctrlB.length}): ` - + `${ctrlBD.added === 0 && ctrlBD.removed === 0 ? '⚠️ NO DELTA — this door has no objects×objects coupling' : `delta +${ctrlBD.added} / -${ctrlBD.removed}`}`, - ); - } - } -} - -if (MODE === 'per-rule') perRuleMode(); -else if (MODE === 'closure') closureMode(); -else if (MODE === 'package') packageMode(); -else totalMode(); diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index e8753b5ebb8..c33287d068f 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -33,7 +33,7 @@ // THE CENSUS is NOT a gate: it exits 0 on any membership count, it prints "a // MEASUREMENT, not a gate" on every run of it, and the file is deliberately not // named `check:*` or `gen:*` so the #4203 script ledger has nothing to classify -// (the shape `measure-partial-retirement-annotation.mjs` established). The only +// (the shape `scripts/measure-partial-retirement-annotation.mjs`, held at 7111e14ac9, established). The only // non-zero exits are a `--self-test` failing its declared controls, an unknown // `--self-test=` mode (REFUSED, exit 2), and `ts-parse`'s EXIT_UNPARSEABLE. // diff --git a/scripts/measure-markdown-ts-blocks.mjs b/scripts/measure-markdown-ts-blocks.mjs deleted file mode 100644 index 12ec6b3955b..00000000000 --- a/scripts/measure-markdown-ts-blocks.mjs +++ /dev/null @@ -1,1276 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * measure-markdown-ts-blocks -- do the fenced TypeScript blocks in package-root - * Markdown compile? (#18715) - * - * node scripts/measure-markdown-ts-blocks.mjs # the census - * node scripts/measure-markdown-ts-blocks.mjs --json # machine-readable - * node scripts/measure-markdown-ts-blocks.mjs --only # one file (repeatable) - * node scripts/measure-markdown-ts-blocks.mjs --controls-only # just the two controls - * node scripts/measure-markdown-ts-blocks.mjs --self-test # the extractor's own battery - * - * ⛔ THIS IS A MEASUREMENT INSTRUMENT, NOT A GATE. It exits 0 on any census - * outcome, is wired into no CI job and no package `test`/`lint` chain, and - * nothing in this repository fails because of what it reports. Turning a - * census into a required gate is a NEW REQUIRED GATE — the maintainer's floor - * — and the wiring options belong in a letter to the maintainer, never in a - * `package.json` edit made by whoever wrote the instrument. - * - * It exits non-zero for exactly one reason: THE INSTRUMENT ITSELF COULD NOT - * RUN (its controls misbehaved, or the workspace is not built). "Could not - * run" is a failure, not a skip — a census that silently degrades to "0 - * failures" because nothing resolved is the worst possible output. - * - * ## POPULATION - * - * Markdown sitting at the ROOT of a package: every `*.md` directly inside a - * directory under `packages/` that has a `package.json`. Two strata are - * reported separately and ⛔ never summed into one headline: - * - * - HAND-WRITTEN — README.md, ARCHITECTURE.md, PHASE2_IMPLEMENTATION.md, … - * A failing block here is a defect an author can repair. - * - CHANGELOG.md — RELEASE-OWNED and compiled by `changeset version` from - * `.changeset/*.md` (AGENTS.md, Documentation Guardrails). Its blocks are - * HISTORY: before/after migration snippets that deliberately show removed - * APIs. A failing block here is frequently CORRECT documentation, and it - * cannot be repaired in a code PR at all. Counting the two together - * produces a number nobody can act on. - * - * `content/docs/**` is a DIFFERENT population with its own gates and is out of - * scope here (#18715 holds it out explicitly). - * - * ## THE ELISION CONVENTION — the one real design question, and its answer - * - * These documents are full of partial snippets. A census that counts every - * partial block as a failure measures nothing. So how does a block declare - * itself partial? - * - * ⛔ THE OBVIOUS ANSWER IS DISQUALIFIED, and this instrument proves it rather - * than asserting it. The obvious answer is an EXCLUSION rule: a block carrying - * an elision marker (`// ...`) is skipped. Run that rule against the one - * failure this repository has already PROVEN — `PHASE2_IMPLEMENTATION.md`'s - * pre-#18712 `kernel.logger` block, measured at `tsc --noEmit --strict` exit 2 - * with TS2341 — and it is excluded, because that block ends with the line - * `// ... plugin registration code ...`. An exclusion rule would have hidden - * the only defect in this family anyone has ever measured. That is the whole - * argument, and `FIRING_CONTROL` below is the standing proof of it. - * - * ✅ THE RULE THIS INSTRUMENT USES IS A TOLERANCE RULE, not an exclusion rule. - * Every block is compiled. What a legitimately partial block can PRODUCE is - * forgiven; nothing else is: - * - * forgiven TS2304 / TS2552 / TS2503 a name or namespace the block elided - * forgiven TS2307 on a RELATIVE specifier the reader's own file - * (`./my-kernel`, `./objectstack.config.js`) — never a - * `@objectstack/*` one, see below - * counted everything else, including TS2341 (private member), TS2339 - * (no such property), TS2345 (wrong argument type), TS2305 / - * TS2724 (no such export) and TS2307 on a `@objectstack/*` - * specifier - * - * TS2307 on a `@objectstack/*` specifier is deliberately NOT forgiven: the - * `paths` map this instrument compiles against is generated from each - * package's own `exports` map, so an unresolved one means the document tells a - * reader to import a subpath the package does not publish. That is the - * `check-published-readme-exports` family of defect, arriving here for free. - * - * ## BOTH NUMBERS, AND WHICH DIRECTION EACH ONE LIES IN - * - * The census reports RAW and TOLERANT side by side, and neither is "the" - * answer: - * - * RAW every block with at least one diagnostic. An UPPER BOUND: it - * counts fragments that were never meant to stand alone. - * TOLERANT every block with at least one diagnostic outside the forgiven - * set. A LOWER BOUND, and this is the direction that matters: a - * forgiven TS2304 leaves that name typed `any`, and every - * downstream use of it is then unchecked. Real defects hide behind - * an elision; they never appear because of one. - * - * ⇒ The honest sentence is "between TOLERANT and RAW", and the gate question - * is answered by TOLERANT, because that is the number a gate could be built to - * hold at zero. - * - * ## THE FORWARD CONVENTION, for the blocks tolerance cannot absolve - * - * A block that is a bare fragment — a type signature, half an object literal — - * produces SYNTAX errors, and ⛔ syntax is not forgiven: a tolerance rule that - * forgave TS1xxx would forgive a typo. Those blocks need an explicit - * declaration, and the proposed spelling is an info-string tag: - * - * ```ts partial - * - * ⛔ Zero blocks in the corpus carry it today, so it contributes nothing to - * this census — it is stated so the census can COUNT how many blocks would - * need one (`needs-explicit-partial-tag` below), which is the cost line the - * wiring letter needs. An `exclusion` census is also printed, labelled as the - * disqualified reading, so the number cannot hide inside the convention. - * - * ## THE TWO CONTROLS — run on EVERY census, not only under --self-test - * - * An instrument that finds zero failures and was never shown capable of - * finding one has measured nothing. - * - * GREEN_CONTROL a block that MUST compile. It fails when the workspace is - * not built (no `dist/*.d.ts` to resolve against), which is - * the state in which every other block would report TS2307 - * and the census would read as a catastrophe made of - * nothing. The run REFUSES instead of printing it. - * FIRING_CONTROL the reconstructed pre-#18712 `kernel.logger` block, which - * MUST be reported as failing, MUST carry TS2341, and MUST - * still fail under the tolerance rule. It is also the proof - * that the exclusion rule is disqualified: the same fixture - * is reported as EXCLUDED by that rule, every run. - * - * Both go through the identical path as corpus blocks — same extractor, same - * normalisation, same program — so a control green is a statement about the - * instrument that actually ran. - * - * ## NORMALISATION, stated because it changes the bytes compiled - * - * 1. The opening fence's indentation is stripped from every body line, so a - * block inside a list item compiles as the code the reader sees. - * 2. `export {};` is appended to every block. One tsc program holds every - * block at once; without it a block with no import/export is a SCRIPT in - * the global scope and collides with every other one (TS2451 on names - * like `kernel`), which would be a census of this instrument's own - * packaging. Appending at the end leaves every reported line number - * equal to the line number in the Markdown. - * - * ## WHY THE `tsc` BINARY, AND WHY TWO PASSES - * - * The TypeScript compiler API is deliberately not used. `scripts/ts-parse.mjs` - * is the one sanctioned door to it under `scripts/**`, and its contract is to - * EXIT on a source it cannot parse — correct for a gate walking real files, - * wrong here, where a block that does not parse is the measurement's subject. - * Driving the binary also makes this instrument ask #18715's own question - * verbatim: `tsc --noEmit --strict`. - * - * ⚠️ MEASURED PROPERTY OF THE CLI, which one pass gets wrong: when any file in - * a program has a SYNTACTIC diagnostic, tsc reports the syntax errors and - * never type-checks ANY file in that program. One pass over this corpus - * returned 772 TS1xxx and not one semantic diagnostic — the firing control - * included, which is exactly how this was caught. So pass 1 finds the blocks - * that do not parse and pass 2 re-runs over the ones that do. A block that - * does not parse carries its syntax diagnostics and no semantic ones, which is - * honest: tsc cannot read it either. - * - * This instrument was first written against `ts.createProgram` + - * `getPreEmitDiagnostics` and then rewritten onto the binary. Over all 1002 - * corpus blocks the two implementations agree on every block's verdict — raw, - * tolerant and well-formed-and-wrong — with ZERO disagreements. The binary run - * reports fewer DIAGNOSTICS on unparseable blocks, because the checker noise - * the API produced over wreckage is gone. - * - * ## COMPILER OPTIONS, and why each deviation from the repo's own tsconfig - * - * strict: true — #18715's proven measurement is `tsc --noEmit - * --strict`; kept verbatim so this instrument and - * that reading answer the same question. - * moduleResolution: — `exports` maps are honoured (so an unpublished - * Bundler subpath still fails) WITHOUT NodeNext's - * mandatory `.js` extension on relative - * specifiers, which would manufacture a failure - * out of a documentation style choice. - * noUnusedLocals: false — a snippet that declares a value to SHOW it is - * noUnusedParameters: not a defect. The repo's own tsconfig turns - * false these on for source; doing so here would count - * prose as breakage. - * lib: ES2022 + DOM — deliberately generous. Every ambient name the - * types: node instrument can supply is one fewer manufactured - * failure. - */ - -import { readFileSync, existsSync, readdirSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; -import { spawnSync } from 'node:child_process'; - -import { isEntrypoint } from './invoked-as.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(HERE, '..'); -const PACKAGES_DIR = join(REPO_ROOT, 'packages'); -const nodeRequire = createRequire(import.meta.url); -// ⛔ The TypeScript compiler API is deliberately NOT imported here. This -// instrument's SUBJECT is blocks that may not parse, so `createProgramChecked` -// — the one sanctioned API door under `scripts/**` — is the wrong tool: it -// exits 3 on the first syntactic diagnostic, which for this census is DATA. -// Driving the real `tsc` binary answers exactly the question #18715 measured -// (`tsc --noEmit --strict`, exit 2, TS2341) and keeps the parser home's rule -// that nothing outside it reaches the parser API. -const TSC_BIN = join(dirname(nodeRequire.resolve('typescript/package.json')), 'bin', 'tsc'); - -// ── Population ───────────────────────────────────────────────────────────── - -const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.turbo', '.git', 'json-schema']); - -/** Every directory under `root` that carries a package.json, `root` included. */ -export function findPackageRoots(root, out = []) { - if (!existsSync(root)) return out; - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; - findPackageRoots(join(root, entry.name), out); - } - if (existsSync(join(root, 'package.json'))) out.push(root); - return out; -} - -export function populationFiles() { - const files = []; - for (const pkgRoot of findPackageRoots(PACKAGES_DIR)) { - for (const name of readdirSync(pkgRoot)) { - if (name.endsWith('.md')) files.push(join(pkgRoot, name)); - } - } - return files.sort(); -} - -/** CHANGELOG.md is release-owned history, reported as its own stratum. */ -export const stratumOf = (file) => (/(^|\/)CHANGELOG\.md$/.test(file) ? 'changelog' : 'handwritten'); - -/** The literal `packages/PKG/NAME.md` glob #18715 names, kept as a reportable sub-stratum. */ -export const isDepthOne = (relPath) => relPath.split('/').length === 3; - -// ── Extraction ───────────────────────────────────────────────────────────── - -const FENCE_RE = /^([ \t]*)(`{3,}|~{3,})[ \t]*(.*)$/; -const TS_LANGS = new Set(['ts', 'typescript', 'tsx']); - -/** - * Fenced blocks whose info-string's FIRST word is a TypeScript language. - * A fence opened inside an already-open block is body text, never a new block: - * a ```ts fence quoted inside a ```md example is not code this repo ships. - */ -export function extractBlocks(text) { - const lines = text.split('\n'); - const blocks = []; - let open = null; - for (let i = 0; i < lines.length; i += 1) { - const m = FENCE_RE.exec(lines[i]); - if (!m) { - if (open) open.body.push(lines[i]); - continue; - } - const [, indent, fence, info] = m; - if (!open) { - open = { indent, char: fence[0], len: fence.length, info: info.trim(), firstBodyLine: i + 2, body: [] }; - continue; - } - const closes = fence[0] === open.char && fence.length >= open.len && info.trim() === ''; - if (!closes) { - open.body.push(lines[i]); - continue; - } - const lang = open.info.split(/\s+/)[0].toLowerCase(); - if (TS_LANGS.has(lang)) { - const dedent = open.indent.length - ? open.body.map((l) => (l.startsWith(open.indent) ? l.slice(open.indent.length) : l.replace(/^[ \t]+/, ''))) - : open.body; - blocks.push({ - lang, - info: open.info, - firstBodyLine: open.firstBodyLine, - declaredPartial: /(^|\s)partial(\s|$)/.test(open.info), - code: dedent.join('\n'), - }); - } - open = null; - } - return blocks; -} - -/** - * The INFERRED elision marker: a comment carrying an ellipsis, or a bare `...` - * line. Used ONLY to compute the disqualified `exclusion` reading — the - * tolerance rule never consults it. - */ -export function hasElisionMarker(code) { - return code.split('\n').some((line) => { - const t = line.trim(); - if (t === '...') return true; - if (t.startsWith('//') && t.includes('...')) return true; - if (t.startsWith('/*') && t.includes('...')) return true; - if (t.startsWith('*') && t.includes('...')) return true; - return false; - }); -} - -/** One tsc program holds every block, so every block must be a module. */ -export const normalise = (code) => `${code}\nexport {};\n`; - -// ── Diagnostic classification ────────────────────────────────────────────── - -const FORGIVEN_NAME_CODES = new Set([2304, 2552, 2503]); -// "Cannot find name 'it' / 'describe' / 'process' / 'document'" — the READER's -// ambient environment (a test runner, node, the DOM), never something the -// document could have written. Same for a third-party module that ships no -// types, and for JSX with no `JSX.IntrinsicElements` in scope. -const FORGIVEN_AMBIENT_CODES = new Set([2580, 2581, 2582, 2583, 2584, 2591, 2592, 2593, 7016, 7026]); -const SYNTAX_MAX = 1999; - -/** - * Pure so the self-test can drive it without a compiler: takes the two fields - * of a diagnostic that decide its family. - */ -export function classifyDiagnostic({ code, message }) { - if (code >= 1000 && code <= SYNTAX_MAX) return { family: 'syntax', forgiven: false }; - if (FORGIVEN_NAME_CODES.has(code)) return { family: 'elided-name', forgiven: true }; - if (FORGIVEN_AMBIENT_CODES.has(code)) return { family: 'missing-ambient-environment', forgiven: true }; - if (code === 2307) { - const spec = /Cannot find module '([^']+)'/.exec(message)?.[1] ?? ''; - if (spec.startsWith('.')) return { family: 'doc-local-path', forgiven: true, specifier: spec }; - if (spec.startsWith('@objectstack/')) return { family: 'unpublished-subpath', forgiven: false, specifier: spec }; - return { family: 'external-not-installed', forgiven: true, specifier: spec }; - } - if (code >= 7000 && code <= 7099) return { family: 'implicit-any', forgiven: false }; - return { family: 'semantic', forgiven: false }; -} - -/** The families `classifyDiagnostic` marks forgiven — a legitimately partial block can produce these and nothing else. */ -export const FORGIVEN_FAMILIES = new Set(['elided-name', 'doc-local-path', 'external-not-installed', 'missing-ambient-environment']); - -/** - * `tsc --pretty false` prints one diagnostic per line, with wrapped detail - * indented under it. Parsed here rather than read from the API, and held - * honest by the two controls: if this parser broke, GREEN_CONTROL would read - * as failing or FIRING_CONTROL as clean, and the run would refuse. - */ -export function parseTscOutput(text) { - const HEAD = /^(\S[^(]*)\((\d+),(\d+)\): (?:error|warning) TS(\d+): (.*)$/; - const BARE = /^(?:error|warning) TS(\d+): (.*)$/; - const diagnostics = []; - const global = []; - for (const line of text.split('\n')) { - const head = HEAD.exec(line); - if (head) { - diagnostics.push({ file: head[1], line: Number(head[2]), code: Number(head[4]), message: head[5] }); - continue; - } - const bare = BARE.exec(line); - if (bare) { - global.push({ code: Number(bare[1]), message: bare[2], line: null }); - continue; - } - const tail = diagnostics.length > 0 ? diagnostics : global; - if (/^\s+\S/.test(line) && tail.length > 0) tail[tail.length - 1].message += ` ${line.trim()}`; - } - return { diagnostics, global }; -} - -// ── Controls ─────────────────────────────────────────────────────────────── - -/** MUST compile. Its failure means the workspace is not built — refuse, never report. */ -export const GREEN_CONTROL = [ - '# green control', - '', - '```ts', - "import { ObjectKernel } from '@objectstack/core';", - '', - 'const KernelCtor: typeof ObjectKernel = ObjectKernel;', - 'export { KernelCtor };', - '```', - '', -].join('\n'); - -/** - * MUST be reported failing, with TS2341, under BOTH readings. - * Reconstructed byte-for-byte from PR #18712's diff (the `-` side of - * packages/core/PHASE2_IMPLEMENTATION.md's "Integration with Kernel" block), - * the one failure in this family anyone has measured: `tsc --noEmit --strict` - * exit 2, TS2341 `Property 'logger' is private`. - * - * ⭐ Its last comment line is `// ... plugin registration code ...`, which is - * why the exclusion reading is reported as DISQUALIFIED rather than offered. - */ -export const FIRING_CONTROL = [ - '# firing control', - '', - '```typescript', - 'import { ', - ' ObjectKernel,', - ' PluginHealthMonitor,', - ' HotReloadManager,', - ' DependencyResolver,', - ' PluginPermissionManager,', - ' PluginSandboxRuntime', - "} from '@objectstack/core';", - '', - "const kernel = new ObjectKernel({ logger: { level: 'info' } });", - '', - '// Initialize Phase 2 components', - 'const healthMonitor = new PluginHealthMonitor(kernel.logger);', - 'const hotReload = new HotReloadManager(kernel.logger);', - 'const depResolver = new DependencyResolver(kernel.logger);', - 'const permManager = new PluginPermissionManager(kernel.logger);', - 'const sandbox = new PluginSandboxRuntime(kernel.logger);', - '', - '// Register plugins with enhanced features', - '// ... plugin registration code ...', - '', - '// Bootstrap kernel', - 'await kernel.bootstrap();', - '```', - '', -].join('\n'); - -/** - * The three specifiers UNPUBLISHED_SUBPATH_CONTROL exists to keep COUNTED. The - * block below and this list are held equal by a self-test battery, so editing - * one without the other is a failure rather than a silent narrowing. - */ -export const UNPUBLISHED_SUBPATH_SPECIFIERS = Object.freeze([ - '@objectstack/spec/hub', - '@objectstack/core/errors', - '@objectstack/core/plugin', -]); - -/** - * MUST be reported failing, with a TS2307 on EACH of the three specifiers - * above, every one of them classified `unpublished-subpath` and forgiven by - * nothing. - * - * Reconstructed line-for-line from `packages/spec/V3_MIGRATION_GUIDE.md` as it - * read BEFORE #18753 repaired it. That document announced a migration that - * never happened: `466f32ea22` deleted `createErrorResponse`, - * `getHttpStatusForCategory` and `definePlugin` from spec and created neither - * destination, so the guide told a reader to import from subpaths their - * packages publish in no `exports` entry. - * - * ⭐ SYNTHETIC ON PURPOSE, and it is the whole reason this fixture exists. A - * control that IS the live document disarms itself the moment the document is - * repaired — the census number falls, and the fourth instance in the next guide - * is just as green. FIRING_CONTROL was reconstructed from PR #18712's diff for - * exactly this reason; this is the same move for the `unpublished-subpath` - * family, which FIRING_CONTROL (a TS2341) does not cover at all. - * - * ⭐ WHAT IT GUARDS IS THE TOLERANCE RULE, not the corpus. Widen - * `classifyDiagnostic` until a `@objectstack/*` TS2307 is forgiven and every - * unresolvable documented import in this repository goes quiet at once — with - * no other control able to notice. That is why it sits on the refusal list - * beside the other two instead of firing only under `--self-test`. - * - * ⚠️ THE COST, stated because it is real: if one of the three subpaths is ever - * genuinely published, this control stops producing TS2307 for it and EVERY - * census refuses until someone edits the fixture. That is the same exposure - * FIRING_CONTROL already carries (make `ObjectKernel.logger` public and it - * stops producing TS2341), and it is answered the same way — the refusal text - * names the remedy instead of leaving a stale fixture to be diagnosed. - * - * ⛔ It carries NO elision marker and NO syntax error, both load-bearing: a - * marker would let the (disqualified) exclusion reading skip it, and a syntax - * error would land it in pass 1's unparseable set, where tsc never type-checks - * it and the three TS2307s it exists to produce would never be reported at all. - */ -export const UNPUBLISHED_SUBPATH_CONTROL = [ - '# unpublished-subpath control', - '', - '```typescript', - "import { TenantSchema } from '@objectstack/spec/hub';", - "import { createErrorResponse, getHttpStatusForCategory } from '@objectstack/core/errors';", - "import { definePlugin } from '@objectstack/core/plugin';", - '```', - '', -].join('\n'); - -// ── Resolution: a `paths` map generated from each package's own exports map ── - -/** Walk a conditional exports value for the `types` condition an importer sees. */ -export function pickTypesTarget(value) { - if (typeof value === 'string') return /\.d\.[cm]?ts$/.test(value) ? value : null; - if (!value || typeof value !== 'object') return null; - if (typeof value.types === 'string') return value.types; - for (const condition of ['import', 'node', 'default', 'require', 'browser']) { - if (condition in value) { - const found = pickTypesTarget(value[condition]); - if (found) return found; - } - } - return null; -} - -/** - * ⛔ A subpath with no entry here gets NO mapping, so it fails TS2307 and is - * counted — that is the point: the census sees exactly what a consumer sees. - */ -export function workspacePaths(packageRoots) { - const paths = {}; - for (const dir of packageRoots) { - let manifest; - try { - manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); - } catch { - continue; - } - if (!manifest.name || manifest.private === true) continue; - const add = (specifier, target) => { - if (target) paths[specifier] = [join(dir, target)]; - }; - if (manifest.exports && typeof manifest.exports === 'object') { - for (const [key, value] of Object.entries(manifest.exports)) { - if (!key.startsWith('.')) continue; - const specifier = key === '.' ? manifest.name : `${manifest.name}/${key.slice(2)}`; - add(specifier, pickTypesTarget(value)); - } - } else if (typeof manifest.exports === 'string') { - add(manifest.name, pickTypesTarget(manifest.exports)); - } - if (!paths[manifest.name] && typeof manifest.types === 'string') add(manifest.name, manifest.types); - } - return paths; -} - -/** Third-party packages live in each package's own store under pnpm, never at the root. */ -export function externalPaths(specifiers, packageRoots) { - const paths = {}; - const searchRoots = [REPO_ROOT, ...packageRoots]; - for (const specifier of specifiers) { - if (specifier.startsWith('.') || specifier.startsWith('@objectstack/') || specifier.startsWith('node:')) continue; - const parts = specifier.split('/'); - const pkgName = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; - // An @types package FIRST: a runtime package that ships no declarations would - // otherwise resolve and then report TS7016, turning "this checkout has no - // types for react" into a census of the documentation. - const typesName = pkgName.startsWith('@') ? `@types/${pkgName.slice(1).replace('/', '__')}` : `@types/${pkgName}`; - for (const root of searchRoots) { - for (const name of [typesName, pkgName]) { - const candidate = join(root, 'node_modules', name); - if (!existsSync(candidate)) continue; - paths[pkgName] = [candidate]; - paths[`${pkgName}/*`] = [join(candidate, '*')]; - break; - } - if (paths[pkgName]) break; - } - } - return paths; -} - -const IMPORT_SPECIFIER_RE = /(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]([^'"]+)['"]/g; -export const specifiersIn = (code) => [...code.matchAll(IMPORT_SPECIFIER_RE)].map((m) => m[1]); - -// ── Compilation ──────────────────────────────────────────────────────────── - -function compilerOptions(paths) { - return { - target: 'ES2022', - lib: ['ES2022', 'DOM', 'DOM.Iterable'], - module: 'ESNext', - moduleResolution: 'Bundler', - jsx: 'react-jsx', - strict: true, - noEmit: true, - skipLibCheck: true, - esModuleInterop: true, - allowSyntheticDefaultImports: true, - resolveJsonModule: true, - forceConsistentCasingInFileNames: true, - noUnusedLocals: false, - noUnusedParameters: false, - // ⛔ No `baseUrl`: TS 6.0 deprecates it, and every `paths` target below is - // already absolute, so there is nothing for it to be relative to. - typeRoots: [join(REPO_ROOT, 'node_modules', '@types')], - types: ['node'], - paths, - }; -} - -/** - * One program for every block at once. Per-block programs would multiply the - * `.d.ts` closure by a thousand; the `export {}` normalisation is what makes - * one program safe. - */ -function compile(units, packageRoots) { - const tmpDir = mkdtempSync(join(tmpdir(), 'os-md-ts-census-')); - const cleanup = () => { - try { - rmSync(tmpDir, { recursive: true, force: true }); - } catch { - /* a leftover temp dir is not worth failing a census over */ - } - }; - process.on('exit', cleanup); - process.on('SIGINT', () => { - cleanup(); - process.exit(130); - }); - process.on('SIGTERM', () => { - cleanup(); - process.exit(143); - }); - try { - units.forEach((unit, index) => { - const name = `block-${String(index).padStart(4, '0')}.${unit.lang === 'tsx' ? 'tsx' : 'ts'}`; - writeFileSync(join(tmpDir, name), normalise(unit.code)); - unit.compiledAs = name; - }); - const specifiers = new Set(units.flatMap((unit) => specifiersIn(unit.code))); - const paths = { ...workspacePaths(packageRoots), ...externalPaths(specifiers, packageRoots) }; - writeFileSync( - join(tmpDir, 'tsconfig.json'), - JSON.stringify({ compilerOptions: compilerOptions(paths), files: units.map((u) => `./${u.compiledAs}`) }, null, 2), - ); - - // The real binary, the same question #18715's reading asked. `cwd` is the - // temp dir so every diagnostic names a bare `block-NNNN.ts`, and stdout and - // stderr are joined because tsc splits config errors from file ones. - // - // ⚠️ TWO PASSES, and the reason is a measured property of `tsc` itself: - // the CLI reports SYNTACTIC diagnostics and then stops — it never type- - // checks ANY file in a program where one file has a syntax error. One pass - // over this corpus returned 913 TS1xxx and not a single semantic - // diagnostic, the firing control included. FIRING_CONTROL is what caught - // that, which is the entire argument for running it on every census. - // So: pass 1 finds the blocks that do not parse, pass 2 re-runs over the - // ones that do. A block that does not parse is never type-checked, which - // is honest — tsc cannot read it either. - const runTsc = (fileList, configName) => { - writeFileSync( - join(tmpDir, configName), - JSON.stringify({ compilerOptions: compilerOptions(paths), files: fileList.map((n) => `./${n}`) }, null, 2), - ); - const run = spawnSync(process.execPath, [TSC_BIN, '--project', configName, '--pretty', 'false'], { - cwd: tmpDir, - encoding: 'utf8', - maxBuffer: 256 * 1024 * 1024, - }); - if (run.error) throw run.error; - return parseTscOutput(`${run.stdout ?? ''}\n${run.stderr ?? ''}`); - }; - - const all = units.map((u) => u.compiledAs); - const pass1 = runTsc(all, 'tsconfig.json'); - const unparseable = new Set(pass1.diagnostics.filter((d) => d.code >= 1000 && d.code <= SYNTAX_MAX).map((d) => d.file)); - const parseable = all.filter((name) => !unparseable.has(name)); - const pass2 = parseable.length > 0 && parseable.length < all.length - ? runTsc(parseable, 'tsconfig.pass2.json') - : { diagnostics: parseable.length > 0 ? pass1.diagnostics : [], global: [] }; - - const global = [...pass1.global, ...pass2.global]; - const byFile = new Map(units.map((u) => [u.compiledAs, []])); - const seen = new Set(); - for (const d of [...pass1.diagnostics, ...pass2.diagnostics]) { - const key = `${d.file}:${d.line}:${d.code}:${d.message}`; - if (seen.has(key)) continue; - seen.add(key); - const bucket = byFile.get(d.file); - if (bucket) bucket.push(d); - else global.push(d); - } - for (const unit of units) { - unit.diagnostics = (byFile.get(unit.compiledAs) ?? []).map((record) => ({ - ...record, - ...classifyDiagnostic(record), - })); - unit.raw = unit.diagnostics.length > 0; - unit.tolerant = unit.diagnostics.some((d) => !d.forgiven); - unit.elided = hasElisionMarker(unit.code); - const unforgiven = unit.diagnostics.filter((d) => !d.forgiven); - unit.syntaxOnly = unforgiven.length > 0 && unforgiven.every((d) => d.family === 'syntax'); - // Parses as a TypeScript module and STILL fails: the class the #18712 - // `kernel.logger` block belongs to, and the only one a gate could hold - // at zero without anyone first tagging a thousand fragments. - unit.wellFormedAndWrong = unit.tolerant && !unit.diagnostics.some((d) => d.family === 'syntax'); - } - return { global, paths }; - } finally { - cleanup(); - } -} - -// ── Census ───────────────────────────────────────────────────────────────── - -function collectUnits(only) { - let files = populationFiles(); - if (only.length) files = files.filter((f) => only.some((o) => relative(REPO_ROOT, f).includes(o))); - const units = []; - for (const file of files) { - const rel = relative(REPO_ROOT, file); - extractBlocks(readFileSync(file, 'utf8')).forEach((block, index) => { - units.push({ ...block, file: rel, index, kind: 'corpus', stratum: stratumOf(rel), depthOne: isDepthOne(rel) }); - }); - } - return { files, units }; -} - -function controlUnits() { - return [ - { ...extractBlocks(GREEN_CONTROL)[0], file: 'control:green', index: 0, kind: 'control', control: 'green', stratum: 'control' }, - { ...extractBlocks(FIRING_CONTROL)[0], file: 'control:firing', index: 0, kind: 'control', control: 'firing', stratum: 'control' }, - { - ...extractBlocks(UNPUBLISHED_SUBPATH_CONTROL)[0], - file: 'control:unpublished-subpath', - index: 0, - kind: 'control', - control: 'unpublished-subpath', - stratum: 'control', - }, - ]; -} - -/** The refusal list. Empty means the instrument proved it can fire before reporting. */ -export function controlProblems(controls) { - const problems = []; - const green = controls.find((c) => c.control === 'green'); - const firing = controls.find((c) => c.control === 'firing'); - if (!green) problems.push('GREEN_CONTROL did not extract to a block — the extractor is broken.'); - else if (green.raw) { - problems.push( - `GREEN_CONTROL did NOT compile (${green.diagnostics.map((d) => `TS${d.code}`).join(', ')}). ` - + 'A block that must compile did not, so nothing else this run would report is a reading. ' - + 'The usual cause is an unbuilt workspace: run `pnpm build` so each package has the ' - + '`dist/*.d.ts` its own exports map points at, then re-run.', - ); - for (const d of green.diagnostics.slice(0, 4)) problems.push(` GREEN_CONTROL TS${d.code}: ${d.message}`); - } - if (!firing) problems.push('FIRING_CONTROL did not extract to a block — the extractor is broken.'); - else { - if (!firing.raw) { - problems.push( - 'FIRING_CONTROL compiled CLEAN. It reconstructs the pre-#18712 `kernel.logger` block, which measured ' - + 'exit 2 / TS2341. An instrument that cannot reproduce the one failure this family has proven has ' - + 'measured nothing, so this run reports nothing.', - ); - } - if (!firing.diagnostics.some((d) => d.code === 2341)) { - problems.push( - 'FIRING_CONTROL did not produce TS2341 (private member access). It may be failing for an unrelated ' - + `reason — it reported: ${firing.diagnostics.map((d) => `TS${d.code}`).join(', ') || 'nothing'}.`, - ); - } - if (firing.raw && !firing.tolerant) { - problems.push('The tolerance rule FORGAVE the firing control — the rule has been widened until it hides the proven defect.'); - } - if (!firing.elided) { - problems.push( - 'FIRING_CONTROL no longer carries an elision marker, so this run cannot demonstrate why the exclusion ' - + 'reading is disqualified. Restore the `// ... plugin registration code ...` line from PR #18712.', - ); - } - } - const subpath = controls.find((c) => c.control === 'unpublished-subpath'); - if (!subpath) problems.push('UNPUBLISHED_SUBPATH_CONTROL did not extract to a block — the extractor is broken.'); - else { - for (const specifier of UNPUBLISHED_SUBPATH_SPECIFIERS) { - const hit = subpath.diagnostics.find((d) => d.code === 2307 && d.specifier === specifier); - if (!hit) { - problems.push( - `UNPUBLISHED_SUBPATH_CONTROL produced no TS2307 for '${specifier}'. Either the compile path no longer ` - + 'reaches this fixture, or that subpath is now PUBLISHED. If it is published, this control is obsolete ' - + 'for that specifier: repoint it at one that is still in no `exports` entry, in the same edit that ' - + 'records why — ⛔ never by deleting the leg, which removes the guard for the other two with it.', - ); - continue; - } - if (hit.family !== 'unpublished-subpath' || hit.forgiven) { - problems.push( - `UNPUBLISHED_SUBPATH_CONTROL's TS2307 for '${specifier}' classified as ${hit.family} / ` - + `forgiven=${hit.forgiven}, not unpublished-subpath / forgiven=false. The tolerance rule has been ` - + 'widened until a documented import that resolves for NO consumer of the published package is forgiven, ' - + 'which silences this whole family of defect at once.', - ); - } - } - if (subpath.raw && !subpath.tolerant) { - problems.push( - 'The tolerance rule FORGAVE the unpublished-subpath control outright, so every unresolvable ' - + '`@objectstack/*` import in the corpus is now invisible to the TOLERANT reading — the one number a gate ' - + 'could be built on.', - ); - } - } - return problems; -} - -const EMPTY_STRATUM = () => ({ - blocks: 0, raw: 0, tolerant: 0, elided: 0, syntaxOnly: 0, wellFormedAndWrong: 0, - exclusionConsidered: 0, exclusionFail: 0, files: new Set(), filesFailing: new Set(), -}); - -export function summarise(units) { - const strata = {}; - const depthOne = EMPTY_STRATUM(); - const families = {}; - for (const unit of units) { - const bucket = (strata[unit.stratum] ??= EMPTY_STRATUM()); - for (const target of unit.depthOne ? [bucket, depthOne] : [bucket]) { - target.blocks += 1; - target.files.add(unit.file); - if (unit.raw) target.raw += 1; - if (unit.tolerant) { - target.tolerant += 1; - target.filesFailing.add(unit.file); - } - if (unit.elided) target.elided += 1; - if (unit.syntaxOnly) target.syntaxOnly += 1; - if (unit.wellFormedAndWrong) target.wellFormedAndWrong += 1; - if (!unit.elided) { - target.exclusionConsidered += 1; - if (unit.raw) target.exclusionFail += 1; - } - } - for (const d of unit.diagnostics) { - const f = (families[d.family] ??= { diagnostics: 0, blocks: new Set(), codes: {} }); - f.diagnostics += 1; - f.blocks.add(`${unit.file}#${unit.index}`); - f.codes[`TS${d.code}`] = (f.codes[`TS${d.code}`] ?? 0) + 1; - } - } - return { strata, depthOne, families }; -} - -const pct = (n, d) => (d === 0 ? ' n/a' : `${((n / d) * 100).toFixed(1).padStart(5)}%`); - -function reportStratum(label, s) { - console.log(`\n ${label}`); - console.log(` files with TS blocks ......... ${s.files.size}`); - console.log(` fenced ts/typescript/tsx .... ${s.blocks}`); - console.log(` RAW fail ............... ${String(s.raw).padStart(4)} ${pct(s.raw, s.blocks)} (upper bound)`); - console.log(` TOLERANT fail ............... ${String(s.tolerant).padStart(4)} ${pct(s.tolerant, s.blocks)} (lower bound — the gate-able number)`); - console.log(` WELL-FORMED AND WRONG ....... ${String(s.wellFormedAndWrong).padStart(4)} ${pct(s.wellFormedAndWrong, s.blocks)} (parses as a module and still fails — the #18712 class)`); - console.log(` files carrying >=1 tolerant fail: ${s.filesFailing.size}`); - console.log(` blocks whose only unforgiven diagnostics are SYNTAX (would need an explicit \`partial\` tag): ${s.syntaxOnly}`); - console.log(` [disqualified] EXCLUSION reading: ${s.exclusionFail} fail of ${s.exclusionConsidered} considered, ${s.elided} blocks skipped for carrying an elision marker`); -} - -function report(units, controls, summary, files) { - const firing = controls.find((c) => c.control === 'firing'); - console.log('markdown TS block census — package-root Markdown under packages/ (#18715)'); - console.log('\nCONTROLS (they run on every census, so a zero is a reading and not a dead search)'); - console.log(' GREEN_CONTROL compiles clean — `@objectstack/*` resolves through the built exports maps.'); - console.log( - ` FIRING_CONTROL reports ${firing.diagnostics.length} diagnostic(s) including ` - + `TS2341 at block line ${firing.diagnostics.find((d) => d.code === 2341)?.line ?? '?'} — ` - + 'the pre-#18712 `kernel.logger` block, reconstructed from that PR\'s diff, is caught.', - ); - console.log( - ' FIRING_CONTROL also carries an elision marker, so the EXCLUSION reading SKIPS it. ' - + 'That is why exclusion is reported below as disqualified rather than offered.', - ); - console.log( - ` UNPUBLISHED_SUBPATH_CONTROL reports TS2307 on all ${UNPUBLISHED_SUBPATH_SPECIFIERS.length} of its specifiers ` - + `(${UNPUBLISHED_SUBPATH_SPECIFIERS.join(', ')}), every one COUNTED as ` - + 'unpublished-subpath — the tolerance rule still refuses to forgive an import that resolves for no consumer.', - ); - - console.log('\nPOPULATION'); - console.log(` package roots scanned ....... ${findPackageRoots(PACKAGES_DIR).length}`); - console.log(` package-root .md files ...... ${files.length}`); - - for (const [label, key] of [['HAND-WRITTEN (repairable by an author)', 'handwritten'], ['CHANGELOG.md (release-owned history)', 'changelog']]) { - if (summary.strata[key]) reportStratum(label, summary.strata[key]); - } - reportStratum('SUB-STRATUM: the literal packages/*/*.md glob #18715 names', summary.depthOne); - - console.log('\nDIAGNOSTIC FAMILIES (every diagnostic on every corpus block)'); - const rows = Object.entries(summary.families).sort((a, b) => b[1].diagnostics - a[1].diagnostics); - for (const [family, f] of rows) { - const mark = FORGIVEN_FAMILIES.has(family) ? 'forgiven' : 'COUNTED '; - const top = Object.entries(f.codes).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([c, n]) => `${c}x${n}`).join(' '); - console.log(` ${mark} ${family.padEnd(24)} ${String(f.diagnostics).padStart(5)} diagnostics in ${String(f.blocks.size).padStart(4)} blocks ${top}`); - } - - console.log('\nPER FILE — blocks / raw fail / tolerant fail (files with at least one tolerant fail)'); - const perFile = new Map(); - for (const unit of units) { - const row = perFile.get(unit.file) ?? { blocks: 0, raw: 0, tolerant: 0, stratum: unit.stratum }; - row.blocks += 1; - if (unit.raw) row.raw += 1; - if (unit.tolerant) row.tolerant += 1; - perFile.set(unit.file, row); - } - const failing = [...perFile.entries()].filter(([, r]) => r.tolerant > 0).sort((a, b) => b[1].tolerant - a[1].tolerant); - for (const [file, r] of failing) { - console.log(` ${String(r.blocks).padStart(4)} ${String(r.raw).padStart(4)} ${String(r.tolerant).padStart(4)} ${file}`); - } - const clean = [...perFile.entries()].filter(([, r]) => r.tolerant === 0); - console.log(`\n ${clean.length} file(s) with TS blocks and zero tolerant failures.`); - console.log('\n⛔ This is a census. It exits 0 on every outcome and gates nothing.'); -} - -function toJson(units, controls, summary, files) { - const plain = (s) => ({ ...s, files: s.files.size, filesFailing: s.filesFailing.size }); - return { - issue: 18715, - population: { packageRoots: findPackageRoots(PACKAGES_DIR).length, markdownFiles: files.length }, - controls: controls.map((c) => ({ - control: c.control, - diagnostics: c.diagnostics.map((d) => ({ code: d.code, family: d.family, forgiven: d.forgiven, line: d.line })), - elided: c.elided, - tolerantFail: c.tolerant, - })), - strata: Object.fromEntries(Object.entries(summary.strata).map(([k, v]) => [k, plain(v)])), - depthOneSubStratum: plain(summary.depthOne), - families: Object.fromEntries(Object.entries(summary.families).map(([k, v]) => [k, { diagnostics: v.diagnostics, blocks: v.blocks.size, codes: v.codes }])), - blocks: units.map((u) => ({ - file: u.file, index: u.index, lang: u.lang, firstBodyLine: u.firstBodyLine, - stratum: u.stratum, elided: u.elided, declaredPartial: u.declaredPartial, - rawFail: u.raw, tolerantFail: u.tolerant, syntaxOnly: u.syntaxOnly, wellFormedAndWrong: u.wellFormedAndWrong, - diagnostics: u.diagnostics.map((d) => ({ code: d.code, family: d.family, forgiven: d.forgiven, line: d.line, message: d.message })), - })), - }; -} - -// ── Self-test ────────────────────────────────────────────────────────────── -// -// Set as `selfTest()`'s LAST statement, after its verdict prints, and read at -// the dispatch: a `return` above the verdict prints nothing and still exits 0, -// which would report a self-test that never finished as one that passed. -let selfTestReachedVerdict = false; - -// The roster is a LITERAL the table is checked against — ⛔ never derived from -// the table, because `cases.length` moves with the table and a deleted row -// would delete its own floor. Each row LABEL is one battery with a floor of 1; -// `registerCase(c.label)` is the FIRST statement of the loop body, so the floor -// asserts REACH and a row whose guard always skipped would read DID NOT RUN. -// -// SCOPE: these batteries cover the EXTRACTOR, the elision proxy, the -// classifier and the fixtures — everything that needs no compiler. The two -// CONTROLS are deliberately not here: they need a built workspace, and they -// run on every census rather than only when someone types `--self-test`. -// `controlProblems()` is their refusal, and it is not optional either. -const SELF_TEST_BATTERIES = Object.freeze({ - 'extract — a plain ```ts fence is a block': 1, - 'extract — a ```typescript fence is a block': 1, - 'extract — a ```tsx fence is a block': 1, - 'extract — a tilde fence is a block': 1, - 'extract — a non-TypeScript language is not a block': 1, - 'extract — an indented fence is dedented to column 0': 1, - 'extract — a ts fence nested inside another fence is body, not a block': 1, - 'extract — an unterminated fence yields no block': 1, - 'extract — an info string carrying `partial` reads as declared-partial': 1, - 'elision — a trailing `// ... plugin registration code ...` is a marker': 1, - 'elision — ordinary comments are not an elision marker': 1, - 'classify — TS2304 is FORGIVEN (the name the block elided)': 1, - 'classify — TS2341 is COUNTED (private member — the one proven defect)': 1, - 'classify — TS2307 on a relative specifier is FORGIVEN': 1, - 'classify — TS2307 on an @objectstack specifier is COUNTED': 1, - 'classify — TS1005 syntax is COUNTED, never forgiven as elision': 1, - 'classify — TS2593 (a test-runner global) is FORGIVEN as the reader\'s ambient environment': 1, - 'classify — TS7016 (a module that ships no declarations) is FORGIVEN as ambient': 1, - 'externals — an @types package is preferred over the runtime package': 1, - 'tsc output — a located diagnostic line is parsed into file, line, code and message': 1, - 'tsc output — an indented continuation line joins the diagnostic above it': 1, - 'tsc output — a diagnostic with no file (a config error) lands in the global bucket': 1, - 'normalise — `export {}` is appended and body line numbers are unchanged': 1, - 'fixture — FIRING_CONTROL extracts to one block that reads kernel.logger': 1, - 'fixture — FIRING_CONTROL carries the elision marker that disqualifies exclusion': 1, - 'fixture — GREEN_CONTROL extracts to one block importing @objectstack/core': 1, - 'fixture — UNPUBLISHED_SUBPATH_CONTROL extracts to one block whose specifiers are exactly UNPUBLISHED_SUBPATH_SPECIFIERS': 1, - 'fixture — UNPUBLISHED_SUBPATH_CONTROL carries no elision marker, so no reading can skip it': 1, - 'classify — every UNPUBLISHED_SUBPATH_SPECIFIERS entry is COUNTED as unpublished-subpath': 1, - 'refusal — a missing TS2307 on a listed specifier REFUSES the census': 1, - 'refusal — a FORGIVEN unpublished-subpath diagnostic REFUSES the census': 1, - 'population — stratumOf separates CHANGELOG.md from hand-written Markdown': 1, - 'population — isDepthOne separates a depth-1 package root from a nested one': 1, - 'exports — pickTypesTarget reads the types entry under the import condition': 1, - 'exports — a subpath a package does not export gets no mapping': 1, -}); - -// 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 = 35; - -const md = (...lines) => lines.join('\n'); - -function selfTest() { - // Healthy stand-ins for the other two controls, so a refusal battery reads - // only the problems the unpublished-subpath leg contributes — and asserts the - // healthy trio reports NONE before asserting a broken one reports some, since - // a `controlProblems` that always complained would otherwise pass. - const healthyGreen = () => ({ control: 'green', raw: false, tolerant: false, elided: false, diagnostics: [] }); - const healthyFiring = () => ({ - control: 'firing', - raw: true, - tolerant: true, - elided: true, - diagnostics: [{ code: 2341, family: 'semantic', forgiven: false }], - }); - const healthySubpathDiagnostics = () => UNPUBLISHED_SUBPATH_SPECIFIERS.map((specifier) => ({ - code: 2307, specifier, family: 'unpublished-subpath', forgiven: false, - })); - const subpathControl = (diagnostics) => ({ control: 'unpublished-subpath', raw: true, tolerant: true, elided: false, diagnostics }); - - const cases = [ - { label: 'extract — a plain ```ts fence is a block', run: () => (extractBlocks(md('```ts', 'const a = 1;', '```')).length === 1 ? null : 'expected 1 block') }, - { label: 'extract — a ```typescript fence is a block', run: () => (extractBlocks(md('```typescript', 'const a = 1;', '```'))[0]?.lang === 'typescript' ? null : 'expected a typescript block') }, - { label: 'extract — a ```tsx fence is a block', run: () => (extractBlocks(md('```tsx', 'const a = ;', '```'))[0]?.lang === 'tsx' ? null : 'expected a tsx block') }, - { label: 'extract — a tilde fence is a block', run: () => (extractBlocks(md('~~~ts', 'const a = 1;', '~~~')).length === 1 ? null : 'expected 1 block from a tilde fence') }, - { label: 'extract — a non-TypeScript language is not a block', run: () => (extractBlocks(md('```json', '{}', '```')).length === 0 ? null : 'json counted as TypeScript') }, - { - label: 'extract — an indented fence is dedented to column 0', - run: () => { - const b = extractBlocks(md('- item', '', ' ```ts', ' const a = 1;', ' ```'))[0]; - return b && b.code === 'const a = 1;' ? null : `expected dedented code, got ${JSON.stringify(b?.code)}`; - }, - }, - { - label: 'extract — a ts fence nested inside another fence is body, not a block', - run: () => (extractBlocks(md('````md', '```ts', 'const a = 1;', '```', '````')).length === 0 ? null : 'a quoted fence was counted as shipped code'), - }, - { label: 'extract — an unterminated fence yields no block', run: () => (extractBlocks(md('```ts', 'const a = 1;')).length === 0 ? null : 'an unterminated fence produced a block') }, - { label: 'extract — an info string carrying `partial` reads as declared-partial', run: () => (extractBlocks(md('```ts partial', 'const a = 1;', '```'))[0]?.declaredPartial === true ? null : 'the partial tag was not read') }, - { label: 'elision — a trailing `// ... plugin registration code ...` is a marker', run: () => (hasElisionMarker('const a = 1;\n// ... plugin registration code ...') ? null : 'marker missed') }, - { label: 'elision — ordinary comments are not an elision marker', run: () => (hasElisionMarker('// build the kernel\nconst a = 1;') ? 'an ordinary comment read as an elision' : null) }, - { label: 'classify — TS2304 is FORGIVEN (the name the block elided)', run: () => (classifyDiagnostic({ code: 2304, message: "Cannot find name 'kernel'." }).forgiven ? null : 'TS2304 not forgiven') }, - { label: 'classify — TS2341 is COUNTED (private member — the one proven defect)', run: () => (classifyDiagnostic({ code: 2341, message: "Property 'logger' is private." }).forgiven ? 'TS2341 forgiven — the proven defect would be invisible' : null) }, - { label: 'classify — TS2307 on a relative specifier is FORGIVEN', run: () => (classifyDiagnostic({ code: 2307, message: "Cannot find module './my-kernel' or its corresponding type declarations." }).forgiven ? null : 'a doc-local path was counted') }, - { label: 'classify — TS2307 on an @objectstack specifier is COUNTED', run: () => (classifyDiagnostic({ code: 2307, message: "Cannot find module '@objectstack/core/plugin' or its corresponding type declarations." }).forgiven ? 'an unpublished subpath was forgiven' : null) }, - { label: 'classify — TS1005 syntax is COUNTED, never forgiven as elision', run: () => (classifyDiagnostic({ code: 1005, message: "';' expected." }).family === 'syntax' && !classifyDiagnostic({ code: 1005, message: '' }).forgiven ? null : 'syntax forgiven') }, - { label: "classify — TS2593 (a test-runner global) is FORGIVEN as the reader's ambient environment", run: () => (classifyDiagnostic({ code: 2593, message: "Cannot find name 'it'." }).family === 'missing-ambient-environment' ? null : 'a test-runner global was counted against the document') }, - { label: 'classify — TS7016 (a module that ships no declarations) is FORGIVEN as ambient', run: () => (classifyDiagnostic({ code: 7016, message: "Could not find a declaration file for module 'react/jsx-runtime'." }).forgiven ? null : 'a missing third-party declaration was counted against the document') }, - { - label: 'tsc output — a located diagnostic line is parsed into file, line, code and message', - run: () => { - const { diagnostics } = parseTscOutput("block-0007.ts(13,45): error TS2341: Property 'logger' is private and only accessible within class 'ObjectKernel'."); - const d = diagnostics[0]; - if (!d) return 'nothing parsed'; - return d.file === 'block-0007.ts' && d.line === 13 && d.code === 2341 && d.message.startsWith("Property 'logger' is private") - ? null - : `parsed ${JSON.stringify(d)}`; - }, - }, - { - label: 'tsc output — an indented continuation line joins the diagnostic above it', - run: () => { - const { diagnostics } = parseTscOutput('block-0001.ts(3,1): error TS2322: Type A is not assignable to type B.\n Property x is missing.'); - return diagnostics.length === 1 && diagnostics[0].message.includes('Property x is missing.') - ? null - : `got ${diagnostics.length} diagnostic(s): ${JSON.stringify(diagnostics.map((d) => d.message))}`; - }, - }, - { - label: 'tsc output — a diagnostic with no file (a config error) lands in the global bucket', - run: () => { - const { diagnostics, global } = parseTscOutput("error TS5101: Option 'baseUrl' is deprecated."); - return diagnostics.length === 0 && global.length === 1 && global[0].code === 5101 - ? null - : `diagnostics=${diagnostics.length} global=${JSON.stringify(global)}`; - }, - }, - { - label: 'externals — an @types package is preferred over the runtime package', - run: () => { - const dir = mkdtempSync(join(tmpdir(), 'os-md-ts-selftest-')); - try { - mkdirSync(join(dir, 'node_modules', '@types', 'reactish'), { recursive: true }); - mkdirSync(join(dir, 'node_modules', 'reactish'), { recursive: true }); - const paths = externalPaths(['reactish/jsx-runtime'], [dir]); - return paths.reactish?.[0]?.includes(`${'@types'}/reactish`) ? null : `mapped to ${paths.reactish?.[0]}`; - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, - }, - { - label: 'normalise — `export {}` is appended and body line numbers are unchanged', - run: () => { - const b = extractBlocks(md('# t', '', '```ts', 'const a = 1;', '```'))[0]; - if (b.firstBodyLine !== 4) return `firstBodyLine ${b.firstBodyLine}, expected 4`; - const n = normalise(b.code); - return n.startsWith('const a = 1;') && n.includes('export {};') ? null : 'normalisation changed the head of the block'; - }, - }, - { - label: 'fixture — FIRING_CONTROL extracts to one block that reads kernel.logger', - run: () => { - const b = extractBlocks(FIRING_CONTROL); - if (b.length !== 1) return `expected 1 block, got ${b.length}`; - return b[0].code.includes('kernel.logger') ? null : 'the fixture no longer reaches into kernel.logger'; - }, - }, - { label: 'fixture — FIRING_CONTROL carries the elision marker that disqualifies exclusion', run: () => (hasElisionMarker(extractBlocks(FIRING_CONTROL)[0].code) ? null : 'the fixture lost its elision marker, and with it the exclusion argument') }, - { - label: 'fixture — GREEN_CONTROL extracts to one block importing @objectstack/core', - run: () => { - const b = extractBlocks(GREEN_CONTROL); - if (b.length !== 1) return `expected 1 block, got ${b.length}`; - return specifiersIn(b[0].code).includes('@objectstack/core') ? null : 'the green control stopped importing anything'; - }, - }, - { - label: 'fixture — UNPUBLISHED_SUBPATH_CONTROL extracts to one block whose specifiers are exactly UNPUBLISHED_SUBPATH_SPECIFIERS', - run: () => { - const b = extractBlocks(UNPUBLISHED_SUBPATH_CONTROL); - if (b.length !== 1) return `expected 1 block, got ${b.length}`; - const found = specifiersIn(b[0].code); - const expected = [...UNPUBLISHED_SUBPATH_SPECIFIERS]; - return found.length === expected.length && expected.every((s) => found.includes(s)) - ? null - : `the block imports ${JSON.stringify(found)} but the roster is ${JSON.stringify(expected)} — editing one without the other narrows the guard silently`; - }, - }, - { - label: 'fixture — UNPUBLISHED_SUBPATH_CONTROL carries no elision marker, so no reading can skip it', - run: () => (hasElisionMarker(extractBlocks(UNPUBLISHED_SUBPATH_CONTROL)[0].code) - ? 'the fixture gained an elision marker, so the exclusion reading would skip the one leg guarding this family' - : null), - }, - { - label: 'classify — every UNPUBLISHED_SUBPATH_SPECIFIERS entry is COUNTED as unpublished-subpath', - run: () => { - for (const specifier of UNPUBLISHED_SUBPATH_SPECIFIERS) { - const verdict = classifyDiagnostic({ - code: 2307, - message: `Cannot find module '${specifier}' or its corresponding type declarations.`, - }); - if (verdict.family !== 'unpublished-subpath' || verdict.forgiven) { - return `${specifier} classified ${verdict.family} / forgiven=${verdict.forgiven}`; - } - } - return null; - }, - }, - { - label: 'refusal — a missing TS2307 on a listed specifier REFUSES the census', - run: () => { - const healthy = controlProblems([healthyGreen(), healthyFiring(), subpathControl(healthySubpathDiagnostics())]); - if (healthy.length !== 0) return `the healthy control trio already reported ${healthy.length} problem(s): ${healthy[0]}`; - const dropped = UNPUBLISHED_SUBPATH_SPECIFIERS[1]; - const problems = controlProblems([ - healthyGreen(), - healthyFiring(), - subpathControl(healthySubpathDiagnostics().filter((d) => d.specifier !== dropped)), - ]); - return problems.some((p) => p.includes(dropped)) ? null : `dropping ${dropped} produced ${JSON.stringify(problems)}`; - }, - }, - { - label: 'refusal — a FORGIVEN unpublished-subpath diagnostic REFUSES the census', - run: () => { - const target = UNPUBLISHED_SUBPATH_SPECIFIERS[2]; - const widened = healthySubpathDiagnostics().map((d) => (d.specifier === target - ? { ...d, family: 'external-not-installed', forgiven: true } - : d)); - const problems = controlProblems([healthyGreen(), healthyFiring(), subpathControl(widened)]); - return problems.some((p) => p.includes(target) && p.includes('forgiven=true')) - ? null - : `forgiving ${target} produced ${JSON.stringify(problems)}`; - }, - }, - { label: 'population — stratumOf separates CHANGELOG.md from hand-written Markdown', run: () => (stratumOf('packages/core/CHANGELOG.md') === 'changelog' && stratumOf('packages/core/README.md') === 'handwritten' ? null : 'stratum split broken') }, - { label: 'population — isDepthOne separates a depth-1 package root from a nested one', run: () => (isDepthOne('packages/core/README.md') && !isDepthOne('packages/plugins/plugin-auth/README.md') ? null : 'depth-1 sub-stratum broken') }, - { - label: 'exports — pickTypesTarget reads the types entry under the import condition', - run: () => { - const target = pickTypesTarget({ browser: { import: { types: './dist/browser.d.mts' } }, import: { types: './dist/index.d.mts', default: './dist/index.mjs' }, require: { types: './dist/index.d.ts' } }); - return target === './dist/index.d.mts' ? null : `picked ${target}`; - }, - }, - { - label: 'exports — a subpath a package does not export gets no mapping', - run: () => { - const dir = mkdtempSync(join(tmpdir(), 'os-md-ts-selftest-')); - try { - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: '@x/y', exports: { '.': { types: './dist/index.d.ts' } } })); - const paths = workspacePaths([dir]); - return paths['@x/y'] && !paths['@x/y/secret'] ? null : 'an unexported subpath was mapped anyway'; - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }, - }, - ]; - - let failed = 0; - const batterySeen = new Map(); - const registerCase = (name) => batterySeen.set(name, (batterySeen.get(name) ?? 0) + 1); - for (const c of cases) { - registerCase(c.label); - let verdict; - try { - verdict = c.run(); - } catch (error) { - verdict = `threw: ${error.message}`; - } - if (verdict) { - failed += 1; - console.error(` ✗ ${c.label}\n ${verdict}`); - } - } - - const declared = Object.keys(SELF_TEST_BATTERIES); - if (declared.length < SELF_TEST_BATTERY_FLOOR) { - failed += 1; - console.error(` ✗ SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`); - } - for (const [name, count] of batterySeen) { - if (name in SELF_TEST_BATTERIES) continue; - failed += 1; - console.error(` ✗ battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES — a case attributed to no declared battery is one nothing floors.`); - } - for (const name of declared) { - const count = batterySeen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - failed += 1; - console.error(count === 0 - ? ` ✗ battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned.` - : ` ✗ battery "${name}" registered ${count} case(s), below its pinned floor of ${SELF_TEST_BATTERIES[name]}.`); - } - - if (failed > 0) { - console.error(`\n✗ measure-markdown-ts-blocks self-test: ${failed} failure(s) (cases and floor).`); - process.exit(1); - } - console.log(`\n✓ measure-markdown-ts-blocks self-test: ${cases.length} cases pass, ${declared.length} batteries at or above floor.`); - selfTestReachedVerdict = true; -} - -// ── Dispatch ─────────────────────────────────────────────────────────────── - -function main() { - const argv = process.argv.slice(2); - - if (argv.includes('--self-test')) { - selfTest(); - if (!selfTestReachedVerdict) { - console.error( - '\n✗ measure-markdown-ts-blocks self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test that never\n' - + 'finished as a self-test that passed.\n', - ); - process.exit(1); - } - return; - } - - const wantJson = argv.includes('--json'); - const controlsOnly = argv.includes('--controls-only'); - const only = argv.flatMap((arg, i) => (arg === '--only' && argv[i + 1] ? [argv[i + 1]] : [])); - - const packageRoots = findPackageRoots(PACKAGES_DIR); - const { files, units } = controlsOnly ? { files: [], units: [] } : collectUnits(only); - const controls = controlUnits(); - const { global } = compile([...controls, ...units], packageRoots); - - const problems = controlProblems(controls); - for (const g of global) problems.push(`compiler option diagnostic TS${g.code}: ${g.message}`); - if (problems.length > 0) { - console.error('✗ measure-markdown-ts-blocks REFUSES to report a census:\n'); - for (const p of problems) console.error(` ${p}`); - console.error( - '\n"could not run" is a failure, not a skip. A census that degraded to "0 failures" because\n' - + 'nothing resolved would be the worst output this instrument could produce.\n', - ); - process.exit(1); - } - - const summary = summarise(units); - if (wantJson) console.log(JSON.stringify(toJson(units, controls, summary, files), null, 2)); - else if (controlsOnly) { - console.log( - '✓ all three controls behaved: GREEN compiles, FIRING reports TS2341 and survives the tolerance rule, ' - + 'UNPUBLISHED_SUBPATH reports a COUNTED TS2307 on each of its three specifiers.', - ); - } - else report(units, controls, summary, files); -} - -// Guarded so this file can be imported for its exports without running the -// census inside the importer (`scripts/invoked-as.mjs`, `check:entry-guard`). -if (isEntrypoint(import.meta.url)) main(); diff --git a/scripts/measure-partial-retirement-annotation.mjs b/scripts/measure-partial-retirement-annotation.mjs deleted file mode 100644 index 6e09ef10d4d..00000000000 --- a/scripts/measure-partial-retirement-annotation.mjs +++ /dev/null @@ -1,534 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// measure-partial-retirement-annotation -- a ONE-SHOT MEASUREMENT INSTRUMENT for -// the #6635 signal. It is NOT a gate, it is NOT wired into any workflow, and it -// is deliberately not named `check:*` or `gen:*` so the #4203 script ledger has -// nothing to classify. -// -// node scripts/measure-partial-retirement-annotation.mjs # summary -// node scripts/measure-partial-retirement-annotation.mjs --hits # every hit -// node scripts/measure-partial-retirement-annotation.mjs --json # machine -// node scripts/measure-partial-retirement-annotation.mjs --tier A # one tier -// node scripts/measure-partial-retirement-annotation.mjs --file

# one file -// -// ## Why it exists -// -// #6635 proposed a gate: flag a file where a retired symbol's retirement issue -// number appears in SOME but not ALL of that symbol's mentions -- evidence that -// a retirement pass touched the file and provably missed siblings. The -// maintainer ruled on 2026-08-09 that the dispatchable deliverable is the -// MEASUREMENT, not the gate: total hits, a spot-verified true/false-positive -// split, and the exemption count a warning-tier rule would need on day one. -// This script produces those numbers reproducibly. Whether the gate is ever -// built is a separate decision the numbers feed. -// -// ## The signal, precisely -// -// For each (file F, retired symbol S): -// - collect every MENTION of S in F's prose (comments in code files, all text -// in markdown -- see `extractProse`); -// - a mention is ANNOTATED when its stanza cites one of S's retirement issue -// numbers, or frames S historically ("was removed", "until #N", "no longer", -// ...). The card is explicit that historical framing counts as annotated: -// the gate keys on ANNOTATION-presence, never on NAME-presence, which is -// what lets a tombstone error string -- whose whole job is to name the -// retired key -- pass; -// - flag F when at least one mention is annotated AND at least one other is -// not, i.e. someone updated this file and missed a sibling. -// -// A STANZA is the annotation window: a maximal run of consecutive lines with -// non-empty content after comment markers are stripped. In a TSDoc block that -// is the paragraph (` *` alone separates); in markdown it is the paragraph. -// `--window N` swaps it for a plus/minus-N-line window, for sensitivity. -// -// ## The three tiers, and why the split is load-bearing -// -// The repo's retirement playbook records that bare-name matching hits surviving -// families. The inventory is therefore tiered by how distinctive the name is, -// and the tiers are measured separately rather than pooled: -// -// A retired DEF names `ETLPipeline`, `WidgetManifest` distinctive -// B retired KEYS, qualified `Manifest.loading`, `X:key` distinctive -// C retired KEYS, bare `transform`, `tools`, `layout` the noise floor -// -// Tier C is measured to SIZE the noise, not because a gate should ship it. -// -// ## Inventory provenance -// -// The retired-symbol inventory is the repo's own declaration surface: -// -// 1. `RETIRED_DEFS_BY_MAJOR` (packages/spec/src/migrations/registry.ts) -// 2. `RETIRED_KEYS_BY_MAJOR` (same file) -// 3. `retiredKey()` tombstone guidance strings across packages/spec -// -// Issue attribution for (1) and (2) is read from the per-group comments inside -// each array literal, with the leading group (which has no per-group comment, -// only the block's doc comment) curated below in `CURATED_DEF_ISSUES`. Every -// attribution was hand-checked against the registry comments; the curation is -// spelled out rather than inferred so a reader can audit it. Attribution for -// (3) is mechanical: the guidance string states its own issue numbers. - -import { readFileSync, existsSync } from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import { join, relative } from 'node:path'; - -const ROOT = process.cwd(); -const REGISTRY = 'packages/spec/src/migrations/registry.ts'; - -// --------------------------------------------------------------------------- -// Inventory -// --------------------------------------------------------------------------- - -/** - * Retirement issue attribution for both registry tables, CURATED BY HAND from - * the per-group comments in `packages/spec/src/migrations/registry.ts`. - * - * ## Why this is hand-written rather than parsed -- a finding in its own right - * - * A gate would need "retired symbol -> its retirement issue" as DATA. The repo - * does not carry that mapping in machine-readable form: the registry tables are - * flat string arrays and the attribution lives only in free prose above each - * group. Parsing "nearest preceding `#NNNN`" was tried first and is wrong in - * two measured ways, both silent: - * - * - it reads the wrong number when a comment names more than one issue. The - * `transform` trio's header reads "The first entries since #4659 built this - * table (#5552)": #4659 built the TABLE, #5552 is the RETIREMENT. Nearest- - * preceding picks #4659 and every annotation check downstream then looks for - * a number no author ever wrote. - * - it cannot see repo boundaries. The #6946 group's comment cites - * "objectui#3829" and "objectui#3818" -- issue numbers in a DIFFERENT repo, - * indistinguishable from local ones once the prefix is dropped. - * - * So the attribution below is read off the comments by a human and stated - * explicitly, so a reader can audit it against the registry in one pass. - */ -const CURATED_ISSUES = { - // --- RETIRED_DEFS_BY_MAJOR[17] ------------------------------------------- - // "The first entry since #4725 built this table (#5552)" - 'shared/FieldMappingTransform': ['5552'], - // "The ten that follow are #5055 (ADR-0049 enforce-or-remove ...)" - 'ui/WidgetManifest': ['5055'], - 'ui/WidgetLifecycle': ['5055'], - 'ui/WidgetEvent': ['5055'], - 'ui/WidgetProperty': ['5055'], - 'ui/WidgetSource': ['5055'], - 'ui/I18nObject': ['5055'], - 'ui/PluralRule': ['5055'], - 'ui/NumberFormat': ['5055'], - 'ui/DateFormat': ['5055'], - 'ui/LocaleConfig': ['5055'], - // "// #5295 -- system/http-server.zod.ts runtime vocabulary" - 'system/ServerEvent': ['5295'], - 'system/ServerEventType': ['5295'], - 'system/ServerCapabilities': ['5295'], - 'system/ServerStatus': ['5295'], - // "// #6239 -- api/protocol.zod.ts view-management operations" - 'api/ListViewsRequest': ['6239'], - 'api/ListViewsResponse': ['6239'], - 'api/GetViewRequest': ['6239'], - 'api/GetViewResponse': ['6239'], - 'api/CreateViewRequest': ['6239'], - 'api/CreateViewResponse': ['6239'], - 'api/UpdateViewRequest': ['6239'], - 'api/UpdateViewResponse': ['6239'], - 'api/DeleteViewRequest': ['6239'], - 'api/DeleteViewResponse': ['6239'], - // "// #6414 -- automation/etl.zod.ts, the whole L2 layer" (the #6635 specimen) - 'automation/ETLPipeline': ['6414'], - 'automation/ETLPipelineRun': ['6414'], - 'automation/ETLSource': ['6414'], - 'automation/ETLDestination': ['6414'], - 'automation/ETLTransformation': ['6414'], - 'automation/ETLEndpointType': ['6414'], - 'automation/ETLTransformationType': ['6414'], - 'automation/ETLSyncMode': ['6414'], - 'automation/ETLRunStatus': ['6414'], - // "// #4914 -- the plugin manifest's `loading` block" - 'kernel/PluginLoadingConfig': ['4914'], - 'kernel/PluginLoadingStrategy': ['4914'], - 'kernel/PluginPreloadConfig': ['4914'], - 'kernel/PluginCodeSplitting': ['4914'], - 'kernel/PluginDynamicImport': ['4914'], - 'kernel/PluginInitialization': ['4914'], - 'kernel/PluginDependencyResolution': ['4914'], - 'kernel/PluginHotReload': ['4914'], - 'kernel/PluginCaching': ['4914'], - 'kernel/PluginSandboxing': ['4914'], - 'kernel/PluginPerformanceMonitoring': ['4914'], - - // --- RETIRED_KEYS_BY_MAJOR[17] ------------------------------------------- - // Same #5552 retirement as the def above -- one tombstone, three walked shapes. - 'data/ExternalFieldMapping:transform': ['5552'], - 'integration/ConnectorFieldMapping:transform': ['5552'], - 'shared/FieldMapping:transform': ['5552'], - // "// #5775 -- the SDUI component-props reconciliation" - 'ui/ElementRecordPickerProps:displayField': ['5775'], - 'ui/ElementRecordPickerProps:multiple': ['5775'], - 'ui/ElementRecordPickerProps:searchFields': ['5775'], - 'ui/PageCardProps:body': ['5775'], - // "// #6776 -- #5775's count was incomplete" - 'ui/PageTabsProps:type': ['6776'], - // "// #6748 -- ADR-0049 enforce-or-remove on the action-descriptor block" - 'automation/ActionDescriptor:isAsync': ['6748'], - // "// #6361 -- the notification-inbox pagination key" - 'api/ListNotificationsRequest:cursor': ['6361'], - 'api/ListNotificationsResponse:cursor': ['6361'], - // "// #4914 -- the plugin manifest's whole `loading` block" - 'kernel/Manifest:loading': ['4914'], - // "// #6815 -- the per-aggregation DISTINCT flag" - 'data/AggregationNode:distinct': ['6815'], - // "// #6946 -- three SDUI page-component props" (objectui#3829 / objectui#3818 - // are the sibling-repo halves, deliberately not used as the local annotation) - 'ui/PageCardProps:actions': ['6946'], - 'ui/PageHeaderProps:icon': ['6946'], - 'ui/RecordDetailsProps:layout': ['6946'], -}; - -/** Extract the body of `export const ...= {` up to the closing `};`. */ -function tableBody(source, name) { - const start = source.indexOf(`export const ${name}`); - if (start < 0) throw new Error(`table ${name} not found in ${REGISTRY}`); - const open = source.indexOf('{', start); - let depth = 0; - for (let i = open; i < source.length; i++) { - if (source[i] === '{') depth++; - else if (source[i] === '}') { - depth--; - if (depth === 0) return source.slice(open + 1, i); - } - } - throw new Error(`unterminated table ${name}`); -} - -/** - * Walk an array literal's lines, tracking the most recent `// #NNNN` comment - * line as the group's attribution, and attach it to each quoted entry. - */ -function entriesWithIssues(body) { - const out = []; - let current = null; - for (const raw of body.split('\n')) { - const line = raw.trim(); - if (line.startsWith('//')) { - const m = line.match(/#(\d{3,6})/); - if (m) current = [m[1]]; - continue; - } - const m = line.match(/^'([^']+)',?$/); - if (m) out.push({ entry: m[1], issues: current }); - } - return out; -} - -function buildInventory() { - const source = readFileSync(join(ROOT, REGISTRY), 'utf8'); - const symbols = []; - - // --- Tier A: retired def names ------------------------------------------- - for (const { entry, issues } of entriesWithIssues(tableBody(source, 'RETIRED_DEFS_BY_MAJOR'))) { - const attributed = CURATED_ISSUES[entry] ?? issues; - if (!attributed) continue; - const name = entry.split('/').pop(); - symbols.push({ - tier: 'A', - id: entry, - name, - issues: attributed, - // Word-boundary on a PascalCase def name. `\b` is safe here because every - // name in this table is a distinctive multi-word identifier. - pattern: new RegExp(`\\b${name}\\b`, 'g'), - }); - } - - // --- Tiers B and C: retired keys ----------------------------------------- - for (const { entry, issues: parsed } of entriesWithIssues(tableBody(source, 'RETIRED_KEYS_BY_MAJOR'))) { - const issues = CURATED_ISSUES[entry] ?? parsed; - if (!issues) continue; - const [defKey, key] = entry.split(':'); - const shape = defKey.split('/').pop(); - symbols.push({ - tier: 'B', - id: entry, - name: `${shape}.${key}`, - issues, - // Qualified only: `Shape.key` or `Shape:key`. - pattern: new RegExp(`\\b${shape}[.:]${key}\\b`, 'g'), - }); - symbols.push({ - tier: 'C', - id: `${entry} (bare)`, - name: key, - issues, - // The noise floor: a backticked bare key, the way prose spells it. - pattern: new RegExp('`' + key + '`', 'g'), - }); - } - - // --- Tier A (second source): retiredKey() tombstone guidance -------------- - // "`` was removed in @objectstack/spec 17.0.0 (#4661, #4964) -- ..." - // Test files are excluded: they quote SPECIMEN guidance strings (a - // `retired-key-migrate-sentence.test.ts` fixture spells "`x.y` was removed in - // @objectstack/spec 17.0.0 (#0000)"), which are illustrations, not retirements. - const specFiles = gitFiles().filter( - (f) => f.startsWith('packages/spec/src/') && f.endsWith('.ts') && !f.endsWith('.test.ts'), - ); - const seenTombstones = new Set(); - for (const file of specFiles) { - const text = readFileSync(join(ROOT, file), 'utf8'); - const re = /`([A-Za-z][A-Za-z0-9_.]*)`\s+(?:was|were)\s+removed[^\n]{0,200}/g; - let m; - while ((m = re.exec(text))) { - const key = m[1]; - const issues = [...m[0].matchAll(/#(\d{3,6})/g)].map((x) => x[1]); - if (!issues.length) continue; - // Only the qualified spellings; a bare tombstoned key is Tier C material - // and already represented by the registry table. - if (!key.includes('.')) continue; - const id = `tombstone:${key}`; - if (seenTombstones.has(id)) continue; - seenTombstones.add(id); - symbols.push({ - tier: 'B', - id, - name: key, - issues, - pattern: new RegExp(`\\b${key.replace(/\./g, '[.:]')}\\b`, 'g'), - }); - } - } - - return symbols; -} - -// --------------------------------------------------------------------------- -// Corpus -// --------------------------------------------------------------------------- - -let _files = null; -function gitFiles() { - if (_files) return _files; - const out = execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, maxBuffer: 64 * 1024 * 1024 }); - _files = out.toString('utf8').split('\u0000').filter(Boolean); - return _files; -} - -const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs)$/; -const DOC_EXT = /\.(md|mdx)$/; - -function corpus() { - return gitFiles().filter((f) => { - if (f.includes('node_modules/')) return false; - if (f.includes('/dist/') || f.startsWith('dist/')) return false; - return CODE_EXT.test(f) || DOC_EXT.test(f); - }); -} - -/** - * Reduce a source file to its PROSE: comment text and string-literal text, with - * every other character blanked out so line and column numbers survive. A - * markdown file is already prose and is returned unchanged. - * - * Blanking rather than deleting is what keeps a hit's reported line number - * usable for hand-verification, which this measurement depends on. - */ -function extractProse(text, file) { - if (DOC_EXT.test(file)) return text; - const out = new Array(text.length).fill(' '); - let i = 0; - const n = text.length; - let state = 'code'; - let quote = ''; - while (i < n) { - const c = text[i]; - const c2 = text[i + 1]; - if (c === '\n') { - out[i] = '\n'; - if (state === 'line') state = 'code'; - i++; - continue; - } - if (state === 'code') { - if (c === '/' && c2 === '/') { state = 'line'; i += 2; continue; } - if (c === '/' && c2 === '*') { state = 'block'; i += 2; continue; } - if (c === '"' || c === "'" || c === '`') { state = 'string'; quote = c; i++; continue; } - i++; - continue; - } - if (state === 'line') { out[i] = c; i++; continue; } - if (state === 'block') { - if (c === '*' && c2 === '/') { state = 'code'; i += 2; continue; } - out[i] = c; - i++; - continue; - } - // string - if (c === '\\') { i += 2; continue; } - if (c === quote) { state = 'code'; i++; continue; } - out[i] = c; - i++; - } - return out.join(''); -} - -// --------------------------------------------------------------------------- -// Stanzas -// --------------------------------------------------------------------------- - -/** Strip comment furniture so "is this line empty" is a question about content. */ -function stanzaContent(line) { - return line - .replace(/^\s*\*\/?/, '') - .replace(/^\s*\/\//, '') - .replace(/^\s*#{1,6}\s/, '') - .trim(); -} - -/** - * Partition a file's lines into stanzas: maximal runs of lines whose content is - * non-empty. Returns an array of { start, end } (1-based, inclusive). - */ -function stanzas(lines) { - const out = []; - let start = null; - for (let i = 0; i < lines.length; i++) { - const has = stanzaContent(lines[i]).length > 0; - if (has && start === null) start = i; - if (!has && start !== null) { out.push({ start, end: i - 1 }); start = null; } - } - if (start !== null) out.push({ start, end: lines.length - 1 }); - return out; -} - -function stanzaFor(index, list, lines, window) { - if (window > 0) { - return { start: Math.max(0, index - window), end: Math.min(lines.length - 1, index + window) }; - } - for (const s of list) if (index >= s.start && index <= s.end) return s; - return { start: index, end: index }; -} - -/** - * Historical framing -- the card's explicit carve-out. A mention that FRAMES the - * symbol as gone is already updated prose, whether or not it cites the number. - */ -const HISTORICAL = new RegExp( - [ - '\\b(?:was|were|is|are|has been|have been)\\s+(?:since\\s+)?(?:removed|retired|deleted|dropped|renamed|replaced|unpublished)\\b', - '\\bno longer\\b', - '\\buntil\\s+#\\d', - '\\bbefore\\s+#\\d', - '\\bformerly\\b', - '\\bused to\\b', - '\\bretired (?:at|in|by|the|it|this|that|under|whole)\\b', - '\\bremoved (?:at|in|by|the|it|this|that|under)\\b', - '\\bpre-\\d', - '\\bwent with\\b', - '\\bpermanently\\b', - '\\bdoes not exist\\b', - '\\bnever (?:existed|shipped)\\b', - ].join('|'), - 'i', -); - -// --------------------------------------------------------------------------- -// Scan -// --------------------------------------------------------------------------- - -function scan({ tiers, window, only }) { - const inventory = buildInventory().filter((s) => tiers.includes(s.tier)); - const files = only ? [only] : corpus(); - const hits = []; - - for (const file of files) { - const abs = join(ROOT, file); - if (!existsSync(abs)) continue; - let raw; - try { raw = readFileSync(abs, 'utf8'); } catch { continue; } - const prose = extractProse(raw, file); - const lines = prose.split('\n'); - const rawLines = raw.split('\n'); - const stanzaList = stanzas(lines); - - for (const sym of inventory) { - sym.pattern.lastIndex = 0; - if (!sym.pattern.test(prose)) continue; - sym.pattern.lastIndex = 0; - - const mentions = []; - for (let i = 0; i < lines.length; i++) { - sym.pattern.lastIndex = 0; - if (!sym.pattern.test(lines[i])) continue; - const s = stanzaFor(i, stanzaList, lines, window); - const text = lines.slice(s.start, s.end + 1).join('\n'); - const cited = sym.issues.some((n) => new RegExp(`#${n}\\b`).test(text)); - const historical = HISTORICAL.test(text); - mentions.push({ line: i + 1, cited, historical, text: rawLines[i].trim() }); - } - if (mentions.length < 2) continue; - - const annotated = mentions.filter((m) => m.cited); - const bare = mentions.filter((m) => !m.cited && !m.historical); - if (annotated.length === 0 || bare.length === 0) continue; - - hits.push({ - file, - tier: sym.tier, - symbol: sym.name, - id: sym.id, - issues: sym.issues, - mentions: mentions.length, - annotated: annotated.length, - historicalOnly: mentions.filter((m) => !m.cited && m.historical).length, - bare: bare.map((m) => ({ line: m.line, text: m.text })), - citedLines: annotated.map((m) => m.line), - }); - } - } - return { inventory, hits, fileCount: files.length }; -} - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -const argv = process.argv.slice(2); -const arg = (flag, fallback) => { - const i = argv.indexOf(flag); - return i >= 0 ? argv[i + 1] : fallback; -}; - -const tiers = (arg('--tier', 'A,B,C') || '').split(',').map((t) => t.trim()).filter(Boolean); -const window = Number(arg('--window', '0')); -const only = arg('--file', null); -const result = scan({ tiers, window, only: only ? relative(ROOT, only) || only : null }); - -if (argv.includes('--inventory')) { - for (const s of result.inventory) console.log(`${s.tier} ${s.name} <- #${s.issues.join(' #')} (${s.id})`); - console.log(`\ninventory: ${result.inventory.length} symbols`); - process.exit(0); -} - -if (argv.includes('--json')) { - console.log(JSON.stringify(result.hits, null, 2)); - process.exit(0); -} - -const byTier = {}; -for (const h of result.hits) byTier[h.tier] = (byTier[h.tier] ?? 0) + 1; -const filesFlagged = new Set(result.hits.map((h) => h.file)).size; - -if (argv.includes('--hits')) { - for (const h of result.hits) { - console.log(`\n[${h.tier}] ${h.file} ${h.symbol} (retired by #${h.issues.join(', #')})`); - console.log(` mentions=${h.mentions} cited=${h.annotated} historical=${h.historicalOnly} bare=${h.bare.length}`); - console.log(` cited at lines: ${h.citedLines.join(', ')}`); - for (const b of h.bare) console.log(` BARE L${b.line}: ${b.text.slice(0, 150)}`); - } -} - -console.log(`\ncorpus: ${result.fileCount} files, inventory: ${result.inventory.length} symbols, window: ${window || 'stanza'}`); -console.log(`hits (file x symbol): ${result.hits.length}`); -console.log(`files flagged: ${filesFlagged}`); -for (const t of Object.keys(byTier).sort()) console.log(` tier ${t}: ${byTier[t]}`); diff --git a/scripts/measure-reserved-identity-name-census.mjs b/scripts/measure-reserved-identity-name-census.mjs deleted file mode 100644 index 53143f425e1..00000000000 --- a/scripts/measure-reserved-identity-name-census.mjs +++ /dev/null @@ -1,400 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// measure-reserved-identity-name-census -- the #15972 census instrument. -// -// node scripts/measure-reserved-identity-name-census.mjs # declarations -// node scripts/measure-reserved-identity-name-census.mjs --json # machine -// node scripts/measure-reserved-identity-name-census.mjs --self-test # controls only -// node scripts/measure-reserved-identity-name-census.mjs --rows FILE # a deployment's rows -// node scripts/measure-reserved-identity-name-census.mjs --rows-schema # the export shape -// -// READ-ONLY, and that is the ruling, not a preference. Maintainer ruling -// (director seat, summon #20, decision batch #105 item 4, 2026-09-09) on what -// happens to rows that ALREADY collide, verbatim: -// -// 「refuse new writes only. No migration. A read-only census reports existing -// colliding rows to the maintainer; nothing rewrites them (option B -// refused: stored-data migration is the manual floor's own item).」 -// -// So this script opens no database connection, takes no credentials, and its -// `--rows` mode consumes a FILE the operator exported. There is no code path -// here that writes anything anywhere. It is NOT a gate: not wired into any -// workflow, exits 0 on any collision count, and deliberately not named -// `check:*` / `gen:*` so the #4203 script ledger has nothing to classify -- -// the shape `measure-position-name-fold-census.mjs` established. -// -// The only non-zero exits are a failing self-test and an unreadable/malformed -// `--rows` input. -// -// ## What collides, and why the two populations are reported separately -// -// ADR-0068 D2 reserves four names for the framework's built-in identities. -// Since #15972 the write path refuses them on both position doors, so the -// populations below can only be rows that PREDATE the guard: -// -// - `sys_position` rows whose `name` spells a reserved name while their -// `managed_by` is NOT the platform's own provenance. The platform seeds -// these four names per organization on purpose (`bootstrapBuiltinRoles`, -// `managed_by: 'platform'`); those rows are the catalog and are NOT -// collisions. A row with any other provenance is a tenant or package -// definition standing on a reserved name. -// - `sys_user_position` rows whose `position` spells a reserved name. ⚠️ ALL -// of them are collisions -- no writer in any package creates one, so there -// is no legitimate population to subtract. This is the row the card is -// about: it is what makes a plain member LOOK like a built-in identity to -// any reader that reads the name instead of the capability rung. -// -// ## The reserved set is READ from the spec constant, never retyped -// -// The ruling closes the enumeration: 「exactly the ADR-0068 built-in identity -// names, read from the spec constant that declares them (closed enumeration, -// ⛔ not retyped, ⛔ not widened to `org_*` shapes by pattern)」. A census that -// carried its own copy of the four strings would answer a different question -// from the guard it audits the day the constant moves, so the names are parsed -// out of `packages/spec/src/identity/eval-user.zod.ts`. -// -// ⚠️ A parse that silently found nothing would report a comfortable ZERO over -// every population. So the parse has a CONTROL that must fire ({@link -// readReservedNames} throws when it does not), and `--self-test` asserts it. -// -// ## What this instrument CANNOT see, stated up front -// -// The declaration census reads THIS REPOSITORY. Positions and assignments are -// RUNTIME rows: an operator who created a position in Setup, or an integration -// that wrote an assignment row, produces a collision no static census can ever -// see. That population is reachable only through `--rows`, which is why -// `--rows` refuses to call an empty input "zero" -- and why a zero from the -// default mode prints the sentence saying so, every time. - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { isEntrypoint } from './invoked-as.mjs'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(HERE, '..'); -const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next']); -const SPEC_DECL = 'packages/spec/src/identity/eval-user.zod.ts'; - -/** Provenance values that mean "the platform's own catalog row" (A4 #2920 plus its legacy spelling). */ -const PLATFORM_PROVENANCE = new Set(['platform', 'system']); - -/* ------------------------------------------------------------------------- * - * The reserved set — parsed out of the declaring constant - * ------------------------------------------------------------------------- */ - -/** - * The four ADR-0068 D2 names, read from the `BUILTIN_IDENTITY_NAMES` array in - * the spec and resolved through the `BUILTIN_IDENTITY_*` constants it lists. - * - * Throws rather than returning `[]`: a census that cannot find its own subject - * must say so, because the alternative is a zero that reads like an all-clear. - */ -export function readReservedNames(source = read(SPEC_DECL)) { - const consts = new Map(); - const constRe = /export const (BUILTIN_IDENTITY_[A-Z0-9_]+)\s*=\s*'([^']*)'/g; - let m; - while ((m = constRe.exec(source)) !== null) consts.set(m[1], m[2]); - - const arrayRe = /export const BUILTIN_IDENTITY_NAMES\s*=\s*\[([^\]]*)\]/; - const arr = arrayRe.exec(source); - if (!arr) throw new Error(`census control FAILED: no BUILTIN_IDENTITY_NAMES array in ${SPEC_DECL}`); - - const names = []; - for (const raw of arr[1].split(',')) { - const ident = raw.trim(); - if (!ident) continue; - const literal = /^'([^']*)'$/.exec(ident); - if (literal) { names.push(literal[1]); continue; } - if (!consts.has(ident)) { - throw new Error(`census control FAILED: ${ident} is listed in BUILTIN_IDENTITY_NAMES but never declared in ${SPEC_DECL}`); - } - names.push(consts.get(ident)); - } - if (names.length === 0) throw new Error(`census control FAILED: BUILTIN_IDENTITY_NAMES parsed empty in ${SPEC_DECL}`); - return names; -} - -/* ------------------------------------------------------------------------- * - * Corpus scan (declarations) - * ------------------------------------------------------------------------- */ - -function read(rel) { - try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return ''; } -} - -function walk(root, exts, out = []) { - let entries; - try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; } - for (const e of entries) { - if (e.name.startsWith('.')) continue; - const full = path.join(root, e.name); - if (e.isDirectory()) { - if (SKIP_DIRS.has(e.name)) continue; - walk(full, exts, out); - } else if (exts.has(path.extname(e.name))) { - out.push(path.relative(REPO_ROOT, full)); - } - } - return out; -} - -/** - * Every `name: ''` / `position: ''` STRING LITERAL in a - * declaration under `packages/`, `examples/` or `apps/`. - * - * Textual, and bounded on purpose (the sibling census records the same - * reasoning): the failure mode being hunted is an authored literal, and the - * price of a source scan is that it sees only the spellings it knows. It does - * NOT see a name assembled at runtime, and it does not know which key belongs - * to a position declaration versus something else that happens to have a - * `name:` -- so its hits are CANDIDATES a reader adjudicates, never verdicts. - */ -export function scanDeclarations(reserved, files) { - const hits = []; - const alternation = reserved.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); - const re = new RegExp(`\\b(name|position)\\s*:\\s*'(${alternation})'`, 'g'); - for (const rel of files) { - const src = read(rel); - if (!src) continue; - let m; - re.lastIndex = 0; - while ((m = re.exec(src)) !== null) { - hits.push({ file: rel, line: src.slice(0, m.index).split('\n').length, key: m[1], name: m[2] }); - } - } - return hits; -} - -/* ------------------------------------------------------------------------- * - * Deployment rows (`--rows`) - * ------------------------------------------------------------------------- */ - -const ROWS_SCHEMA = ` ---rows FILE expects JSON exported READ-ONLY from the deployment: - - { - "deployment": "", - "sys_position": [ { "id": …, "name": …, "managed_by": …, "organization_id": … }, … ], - "sys_user_position": [ { "id": …, "user_id": …, "position": …, "organization_id": … }, … ] - } - -Both arrays are REQUIRED (an absent one is refused, not read as empty — see -below). Export them with reads only, e.g. against the data API: - - GET /api/v1/data/sys_position?fields=id,name,managed_by,organization_id - GET /api/v1/data/sys_user_position?fields=id,user_id,position,organization_id - -⚠️ An EMPTY array is accepted and reported as zero FOR THAT TABLE; a MISSING -key is refused. The distinction is the whole point: "we exported it and there -were none" and "we never exported it" must not produce the same all-clear. -`.trim(); - -export function censusRows(reserved, payload) { - const problems = []; - for (const table of ['sys_position', 'sys_user_position']) { - if (!Array.isArray(payload?.[table])) problems.push(`'${table}' is missing or not an array`); - } - if (problems.length) { - const err = new Error(`--rows input is not a census: ${problems.join('; ')}`); - err.schema = ROWS_SCHEMA; - throw err; - } - const reservedSet = new Set(reserved); - - const positions = payload.sys_position - .filter((r) => reservedSet.has(String(r?.name ?? ''))) - .map((r) => ({ - id: r?.id ?? null, - name: String(r?.name), - managed_by: r?.managed_by ?? null, - organization_id: r?.organization_id ?? null, - // The platform's own catalog rows are not collisions — they ARE the - // built-in identity catalog, seeded per organization by design. - platformCatalog: PLATFORM_PROVENANCE.has(String(r?.managed_by ?? '')), - })); - - const assignments = payload.sys_user_position - .filter((r) => reservedSet.has(String(r?.position ?? ''))) - .map((r) => ({ - id: r?.id ?? null, - user_id: r?.user_id ?? null, - position: String(r?.position), - organization_id: r?.organization_id ?? null, - })); - - return { - deployment: payload.deployment ?? null, - scanned: { sys_position: payload.sys_position.length, sys_user_position: payload.sys_user_position.length }, - positionCollisions: positions.filter((p) => !p.platformCatalog), - platformCatalogRows: positions.filter((p) => p.platformCatalog), - assignmentCollisions: assignments, - }; -} - -/* ------------------------------------------------------------------------- * - * Self-test — the controls, so a zero can be trusted - * ------------------------------------------------------------------------- */ - -function selfTest() { - const failures = []; - const check = (label, fn) => { - try { fn(); } catch (e) { failures.push(`${label}: ${e.message}`); } - }; - const eq = (a, b, what) => { - const [x, y] = [JSON.stringify(a), JSON.stringify(b)]; - if (x !== y) throw new Error(`${what}: expected ${y}, got ${x}`); - }; - - // 1. The reserved set is found in the real declaration, and it is the four. - check('reserved set parses from the spec', () => { - const names = readReservedNames(); - eq(names.length, 4, 'reserved name count'); - for (const n of names) if (!/^[a-z][a-z0-9_]*$/.test(n)) throw new Error(`not a machine name: ${n}`); - }); - - // 2. THE CONTROL THAT MATTERS — a broken parse must THROW, never report zero. - check('a declaration with no array is refused, not read as empty', () => { - let threw = false; - try { readReservedNames("export const BUILTIN_IDENTITY_PLATFORM_ADMIN = 'platform_admin';"); } - catch { threw = true; } - if (!threw) throw new Error('a source with no BUILTIN_IDENTITY_NAMES array was accepted'); - }); - check('a name listed but never declared is refused', () => { - let threw = false; - try { readReservedNames('export const BUILTIN_IDENTITY_NAMES = [\n BUILTIN_IDENTITY_GHOST,\n] as const;'); } - catch { threw = true; } - if (!threw) throw new Error('an undeclared member was accepted'); - }); - - // 3. The declaration scanner FIRES on a positive control and stays silent on - // the near-misses the ruling refuses to widen to. - check('the declaration scanner fires, and does not widen by pattern', () => { - const reserved = ['platform_admin', 'org_admin']; - const tmp = path.join(REPO_ROOT, 'packages/spec/src/identity/eval-user.zod.ts'); - if (!fs.existsSync(tmp)) throw new Error('control corpus file is missing'); - const hits = scanDeclarations(reserved, [SPEC_DECL]); - // The spec file declares the metadata map keyed by the constants, not by - // literals, so this control is about the SCANNER, run over a synthetic - // corpus below rather than over that file's spelling. - const synthetic = scanSynthetic(reserved, [ - "const a = { name: 'platform_admin' };", - "const b = { position: 'org_admin' };", - "const c = { name: 'org_manager' };", // ⛔ must NOT match - "const d = { name: 'platform_admin_x' };", // ⛔ must NOT match - "const e = { label: 'platform_admin' };", // ⛔ wrong key - ]); - eq(synthetic.map((h) => h.name), ['platform_admin', 'org_admin'], 'synthetic scanner hits'); - if (!Array.isArray(hits)) throw new Error('scanner did not return an array'); - }); - - // 4. `--rows` separates the platform catalog from a real collision, and - // refuses an input that never exported a table. - check('rows census: catalog rows are not collisions, assignments always are', () => { - const out = censusRows(['platform_admin', 'org_admin'], { - sys_position: [ - { id: 'p1', name: 'platform_admin', managed_by: 'platform' }, - { id: 'p2', name: 'platform_admin', managed_by: 'system' }, - { id: 'p3', name: 'org_admin', managed_by: 'admin' }, - { id: 'p4', name: 'sales_manager', managed_by: 'admin' }, - ], - sys_user_position: [ - { id: 'a1', user_id: 'u1', position: 'platform_admin' }, - { id: 'a2', user_id: 'u2', position: 'sales_manager' }, - ], - }); - eq(out.positionCollisions.map((r) => r.id), ['p3'], 'position collisions'); - eq(out.platformCatalogRows.map((r) => r.id), ['p1', 'p2'], 'platform catalog rows'); - eq(out.assignmentCollisions.map((r) => r.id), ['a1'], 'assignment collisions'); - }); - check('rows census: a MISSING table is refused, an EMPTY one is zero', () => { - let threw = false; - try { censusRows(['platform_admin'], { sys_position: [] }); } catch { threw = true; } - if (!threw) throw new Error('an input missing sys_user_position was accepted'); - const out = censusRows(['platform_admin'], { sys_position: [], sys_user_position: [] }); - eq(out.assignmentCollisions.length, 0, 'empty export'); - }); - - if (failures.length) { - console.error('SELF-TEST FAILED'); - for (const f of failures) console.error(` - ${f}`); - process.exit(1); - } - console.log(`self-test OK — ${5} controls, all firing`); -} - -/** Scanner over in-memory lines, for the self-test's synthetic corpus. */ -function scanSynthetic(reserved, lines) { - const alternation = reserved.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); - const re = new RegExp(`\\b(name|position)\\s*:\\s*'(${alternation})'`, 'g'); - const hits = []; - for (const line of lines) { - let m; - re.lastIndex = 0; - while ((m = re.exec(line)) !== null) hits.push({ key: m[1], name: m[2] }); - } - return hits; -} - -/* ------------------------------------------------------------------------- * - * CLI - * ------------------------------------------------------------------------- */ - -const ZERO_CAVEAT = - '⚠️ A zero here is a zero over DECLARATIONS in this repository, never over a deployment. ' - + 'Positions and assignments are runtime rows; use --rows with an export to census a live store.'; - -function main(argv) { - if (argv.includes('--self-test')) return selfTest(); - if (argv.includes('--rows-schema')) { console.log(ROWS_SCHEMA); return; } - - const json = argv.includes('--json'); - const reserved = readReservedNames(); - - const rowsAt = argv.indexOf('--rows'); - if (rowsAt !== -1) { - const file = argv[rowsAt + 1]; - if (!file) { console.error('--rows needs a FILE'); process.exit(2); } - let payload; - try { payload = JSON.parse(fs.readFileSync(file, 'utf8')); } - catch (e) { console.error(`--rows: cannot read ${file}: ${e.message}`); process.exit(2); } - let out; - try { out = censusRows(reserved, payload); } - catch (e) { console.error(e.message); if (e.schema) console.error(`\n${e.schema}`); process.exit(2); } - - if (json) { console.log(JSON.stringify({ mode: 'rows', reserved, ...out }, null, 2)); return; } - console.log(`# reserved identity-name census — deployment rows${out.deployment ? ` (${out.deployment})` : ''}`); - console.log(`reserved set: ${reserved.join(', ')}`); - console.log(`scanned: ${out.scanned.sys_position} sys_position, ${out.scanned.sys_user_position} sys_user_position`); - console.log(`\n## sys_position rows standing on a reserved name (excluding the platform catalog): ${out.positionCollisions.length}`); - for (const r of out.positionCollisions) { - console.log(` - ${r.id} name=${r.name} managed_by=${r.managed_by} organization=${r.organization_id}`); - } - console.log(`\n## sys_user_position rows spelling a reserved name — ALL are collisions: ${out.assignmentCollisions.length}`); - for (const r of out.assignmentCollisions) { - console.log(` - ${r.id} user=${r.user_id} position=${r.position} organization=${r.organization_id}`); - } - console.log(`\n(platform catalog rows seen and NOT counted: ${out.platformCatalogRows.length})`); - console.log('\n⛔ Nothing here is rewritten. Renaming or removing a listed row is a maintainer decision.'); - return; - } - - const files = [ - ...walk(path.join(REPO_ROOT, 'packages'), new Set(['.ts', '.tsx'])), - ...walk(path.join(REPO_ROOT, 'examples'), new Set(['.ts', '.tsx'])), - ...walk(path.join(REPO_ROOT, 'apps'), new Set(['.ts', '.tsx'])), - ].filter((f) => !f.includes('.test.') && !f.includes('.spec.')); - const hits = scanDeclarations(reserved, files); - - if (json) { console.log(JSON.stringify({ mode: 'declarations', reserved, scannedFiles: files.length, hits, caveat: ZERO_CAVEAT }, null, 2)); return; } - console.log('# reserved identity-name census — declarations in this repository'); - console.log(`reserved set: ${reserved.join(', ')} (read from ${SPEC_DECL})`); - console.log(`scanned: ${files.length} non-test source files`); - console.log(`\ncandidate declarations spelling a reserved name: ${hits.length}`); - for (const h of hits) console.log(` - ${h.file}:${h.line} ${h.key}: '${h.name}'`); - console.log(`\n${ZERO_CAVEAT}`); -} - -if (isEntrypoint(import.meta.url)) main(process.argv.slice(2)); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index e252ceeb9ec..8434ef408ac 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -6006,7 +6006,8 @@ export function packageRootAnchoredHint(hint, base, tree, files) { * false: `node_modules`, `dist`, `coverage` and `.turbo` off * check-refd-timer-probe's skip set; `.changeset` twice — off * check-keyed-text-bounds' `SKIP_DIRS` and off the noise floor the card was - * filed on; the two gate paths measurement-claim-triage declares it skips; and + * filed on; the two gate paths measurement-claim-triage declared it skips (that + * tool is removed from the tree, held at 7111e14ac9); and * `scripts/**`, `docs/adr/**` and `.changeset/**` off `DEFERRED_SURFACES`, * which is what the `DEFERRED` arm retired. The changeset pair is what a dev * actually saw when #15753 was filed: a card that has not written its diff --git a/scripts/pm/measurement-claim-triage.mjs b/scripts/pm/measurement-claim-triage.mjs deleted file mode 100644 index 1514945822c..00000000000 --- a/scripts/pm/measurement-claim-triage.mjs +++ /dev/null @@ -1,653 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Tree-measurement claims under `scripts/**` — the CLASSIFIER (#17797). - * - * node scripts/pm/measurement-claim-triage.mjs the population + every verdict - * node scripts/pm/measurement-claim-triage.mjs --review the kind-3 residue alone - * node scripts/pm/measurement-claim-triage.mjs --self-test the controls, both directions - * - * ## Why a tool and not a sentence - * - * The card that asked for this states its own population as a count. That count - * was taken once and then decayed — which is the very defect the card is about, - * reproduced by the card. ⛔ So no number describing this population is written - * down here. The scope line this tool prints IS the number, re-derived on the - * tree it is run against, and a reader who wants the population runs the tool. - * ⭐ The figures are deliberately absent from this docblock; ⛔ do not helpfully - * restore one. - * - * ## The three kinds, and why one phrase carries all of them - * - * A sentence that reports a measurement of this repository is one of three - * things, and they take DIFFERENT repairs: - * - * KIND 1 a citation of a past measurement. It is scoped to a moment — by a - * revision, a date, or a named event ("before this gate was written", - * "at the commit that took it") or a counterfactual condition ("with - * the reservation removed"). It reads as history, so it stays true - * forever. ⇒ NEEDS NOTHING. - * - * KIND 2 a claim whose failure direction is NAMED, and which something in - * the tree announces when it moves — a zero whose falsifier is - * spelled out, a figure a gate reprints, a figure a pin reds on. - * It does not decay SILENTLY, which is the whole harm. ⇒ NEEDS - * NOTHING. - * - * KIND 3 a bare magnitude in the present tense: no moment, no named - * direction, nothing that speaks when it moves. It reads as current - * state and goes false in silence. ⇒ THIS IS THE DEFECT. - * - * ⛔ The phrase alone separates none of them, which is why the population below - * is an UPPER BOUND on a population and NOT a count of defects. Treating the - * sweep's size as a finding count is the error this tool exists to prevent. - * - * ## What is mechanical here, and what is not - * - * Tests T0–T3 run over source text and are recall filters: each one can only - * move a hit OUT of the review residue, and each prints the cue that moved it, - * so a verdict is always auditable against the sentence. What no regex decides - * is T4 — whether a reader acts on the magnitude's LEVEL or on a relation the - * level's drift survives. T4 is a judgement, so it is recorded per site in - * `TRIAGE` below, with its reason, exactly as the bare-root worklist records - * its own. A review hit with no `TRIAGE` row prints as UNTRIAGED and reds the - * self-test: a new claim of this shape has to be judged by someone. - * - * ## Scope, and the file deliberately outside it - * - * `scripts/pm/dispatch-gates.mjs` is EXCLUDED, with its reason carried as data - * in `EXCLUDED` and printed on every run rather than left in prose. The residue - * it holds is therefore un-swept, and the scope line says so instead of reading - * as complete. - * - * ## ⚠️ This tool is not wired into CI - * - * Wiring a `--self-test` needs a `package.json` script and a workflow step, and - * both sit outside the file surface this card was dispatched with. Until that - * lands, `--self-test` is run by hand and its controls are not watching - * anything on their own. ⛔ Do not read a green run here as CI coverage. - */ - -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { join, relative, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { isEntrypoint } from '../invoked-as.mjs'; - -const HERE = fileURLToPath(new URL('.', import.meta.url)); -const REPO_ROOT = join(HERE, '..', '..'); -const SCAN_ROOT = join(REPO_ROOT, 'scripts'); - -/** This file's own path, relative to the repo root, in the spelling verdicts use. */ -const SELF = 'scripts/pm/measurement-claim-triage.mjs'; - -/** - * FORM A — the phrase the card names, in every spelling it actually occurs in - * on this tree. ⚠️ The card's own probe was case-sensitive and lower-case only; - * on this tree that spelling reaches strictly fewer sites than the sentence - * does, and the gap is not a rounding error. The count either way is printed, - * never written down. - */ -const FORM_A = /(?:\bre-)?measured\s+(?:on|against)\s+this\s+tree/gi; - -/** - * FORM B — sites FORM A cannot reach, declared per file with the reason. - * - * ⭐ That this list is non-empty is the finding: a phrase probe is not a - * population. The card's own named instance carries no phrase at all — it is a - * bare corpus size in the present tense — so a sweep defined by the phrase - * would have reported the tree clean at the one site everybody agreed was - * broken. - */ -const FORM_B = [ - { - file: 'scripts/docs-audit/README.md', - // The hand-written docs corpus size. `pnpm check:docs-audit-scope` prints - // today's; every literal of the older figure left in this file is a claim - // about the corpus that the gate can contradict. - pattern: /\b178\b/g, - // `#11178` is a card number, not a corpus size. - reject: /#\d*178\b/, - why: 'the hand-written docs corpus size, which `check:docs-audit-scope` prints live', - }, -]; - -/** Excluded files, with the reason carried as data so every run prints it. */ -const EXCLUDED = [ - { - file: 'scripts/pm/dispatch-gates.mjs', - why: 'edited by open PRs #19162 and #19024, and its `--self-test` exceeds the agent ' - + 'container foreground cap (#17765), so a change here could not be verified from a seat', - }, -]; - -/* ─────────────────────────── the tests ─────────────────────────────────── */ - -/** - * T0 — is there a magnitude at all? Applied LAST, as the weakest escape hatch: - * a sentence that is already anchored or already loud is settled before the - * question is asked, so T0 only ever speaks about the residue. - * - * Nothing decays if nothing is counted. A ZERO is a magnitude — kind 2 is - * defined on one — so the word counts even where no digit does. Revisions, dates, card numbers, tool - * versions and exit codes are struck first: each is an identifier that happens - * to be spelled in digits, and counting them as magnitudes would classify every - * anchored citation as the thing an anchor exists to prevent. - */ -const strikeNonMagnitudes = (text) => text - .replace(/`[0-9a-f]{7,40}`/g, ' ') - .replace(/\b20\d{2}-\d{2}-\d{2}\b/g, ' ') - .replace(/#\d+/g, ' ') - .replace(/\bv?\d+\.\d+(?:\.\d+)?\b/g, ' ') - .replace(/\bexit \d+\b/gi, ' ') - .replace(/\bADR-\d+\b/g, ' ') - .replace(/\bbash \d\.\d\b/gi, ' '); - -const MAGNITUDE = /(? { - const flat = text.replace(/\s+/g, ' '); - for (const [re, name] of cues) if (re.test(flat)) return name; - return null; -}; - -/* ───────────────────── population derivation ───────────────────────────── */ - -const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.turbo']); - -function* walk(dir) { - for (const entry of readdirSync(dir).sort()) { - if (SKIP_DIRS.has(entry)) continue; - const full = join(dir, entry); - const st = statSync(full); - if (st.isDirectory()) yield* walk(full); - else if (st.isFile()) yield full; - } -} - -/** - * ## The window a verdict is read over, and why it is a SENTENCE - * - * The first build of this tool read a fixed ±6-line block. It cleared the one - * site everybody had already agreed was broken (`scripts/docs-audit/README.md`, - * the present-tense corpus size): six lines above it an UNRELATED sentence - * cites a revision, and a block window handed that anchor to a claim that has - * none. ⛔ An anchor belongs to the sentence that carries the figure and to no - * other, so T2 and T3 read the SENTENCE. - * - * T1 and T3 are the exceptions, and deliberately. A narrative frame is - * routinely set one sentence earlier — the dispatcher-vocabulary gate opens - * with "An earlier revision of this paragraph counted DIRECTORIES …" and only - * then reports the reading — so T1 also sees the sentence before. A named failure direction is routinely set one - * sentence LATER — the card's own kind-2 exemplar spells the zero in one - * sentence and names what would falsify it in the next — so T3 sees both - * neighbours. ⛔ T2 sees neither: an anchor belongs to its own sentence, and - * widening T2 is precisely the bug this window replaced. - * - * ⚠️ Neither test sees further than one sentence, and a frame or a direction - * set further back than that lands in the review residue — the safe direction, - * where a person judges it and records the judgement in `TRIAGE`. - */ -const SENTENCE_BREAK = /(?<=[.!?])[ \n]+(?=[A-Z⛔⭐⚠️`*#·•])/; - -/** Comment furniture, so a sentence reads as prose rather than as a comment. */ -function deComment(line, isMarkdown) { - let out = line.replace(/^\s*(?:\/\/+|\*)\s?/, ''); - if (!isMarkdown) out = out.replace(/^\s*#+\s?/, ''); - return out; -} - -/** - * Split a file into sentences, each carrying the 1-based line it starts on. - * The line map is built from the ORIGINAL line count, so a reported line is a - * line in the file a reader will open and not an offset into a derived string. - */ -function sentencesOf(text, isMarkdown) { - const lines = text.split('\n'); - const prose = lines.map((l) => deComment(l, isMarkdown)); - const out = []; - let buffer = []; - let bufferStart = 1; - const flush = () => { - if (!buffer.length) return; - const joined = buffer.join('\n'); - let offset = 0; - for (const piece of joined.split(SENTENCE_BREAK)) { - const before = joined.slice(0, offset); - out.push({ text: piece, line: bufferStart + (before.match(/\n/g) ?? []).length }); - offset += piece.length + 1; - } - buffer = []; - }; - prose.forEach((line, i) => { - if (line.trim() === '') { - // ⛔ A blank comment line does NOT end a sentence that was introducing a - // data block. A docblock here routinely ends its lead-in on a colon and - // then sets the table off with an empty ` *` line; flushing at that blank - // strands every figure in a fragment carrying no cue, and the sweep reads - // a docblock full of magnitudes as carrying none. - if (buffer.length && /:\s*$/.test(buffer[buffer.length - 1])) return; - flush(); - return; - } - if (!buffer.length) bufferStart = i + 1; - buffer.push(line); - }); - flush(); - return out; -} - -function hitsIn(relPath, text) { - const isMarkdown = relPath.endsWith('.md'); - const sentences = sentencesOf(text, isMarkdown); - const found = []; - sentences.forEach((s, i) => { - const lead = i > 0 ? sentences[i - 1].text : ''; - const next = i + 1 < sentences.length ? sentences[i + 1].text : ''; - FORM_A.lastIndex = 0; - if (FORM_A.test(s.text)) { - found.push({ file: relPath, line: s.line, form: 'A', sentence: s.text, lead, next }); - } - for (const form of FORM_B) { - if (form.file !== relPath) continue; - form.pattern.lastIndex = 0; - if (!form.pattern.test(s.text)) continue; - if (form.reject && form.reject.test(s.text)) continue; - found.push({ file: relPath, line: s.line, form: 'B', sentence: s.text, lead, next, why: form.why }); - } - }); - return found; -} - -export function collectPopulation() { - const excluded = new Set(EXCLUDED.map((e) => e.file)); - const hits = []; - for (const full of walk(SCAN_ROOT)) { - const rel = relative(REPO_ROOT, full).split(sep).join('/'); - if (excluded.has(rel) || rel === SELF) continue; - let text; - try { text = readFileSync(full, 'utf8'); } catch { continue; } - hits.push(...hitsIn(rel, text)); - } - return hits; -} - -/** - * The classifier proper: T1–T3 then T0, over one sentence (plus the sentence - * before it, for T1 only). T4 — level or relation — is a judgement and lives in - * `TRIAGE`. - */ -export function classify(sentence, lead = '', next = '') { - const frame = firstCue(FRAME_CUES, `${lead}\n${sentence}`); - if (frame) return { kind: 'KIND-1', cue: frame }; - const anchor = firstCue(ANCHOR_CUES, sentence); - if (anchor) return { kind: 'KIND-1', cue: anchor }; - const loud = firstCue(LOUD_CUES, `${lead}\n${sentence}\n${next}`); - if (loud) return { kind: 'KIND-2', cue: loud }; - if (!MAGNITUDE.test(strikeNonMagnitudes(sentence.replace(/\s+/g, ' ')))) { - return { kind: 'NO-FIGURE', cue: 'no magnitude survives T0' }; - } - return { kind: 'REVIEW', cue: 'present-tense magnitude, no frame, no anchor, nothing loud' }; -} - -/* ─────────────────────────── T4, recorded ───────────────────────────────── */ - -/** - * T4 — LEVEL or RELATION, the half no regex decides. - * - * With T1–T3 all declining, one question is left: does a reader act on the - * magnitude's **level**, or on a **relation** (a ratio, an ordering, an - * existence claim, a set equality) that the level's drift survives? A level is - * kind 3. A relation is an ILLUSTRATION — the figure sizes an argument that - * stays correct when the figure moves, and rewriting it buys nothing while - * costing a reviewer a diff. - * - * ⛔ Every row is keyed by an EXCERPT and never by a line number. The card that - * asked for this cited its own known instance by line, and the line had moved - * before anyone read the card — a ledger keyed on line numbers decays exactly - * the way its subject does. - * - * `verdict`: - * ILLUSTRATION — judged a relation; stays as written. - * REPAIRED — judged kind 3 and repaired in #17797. The self-test holds - * the excerpt ABSENT: if it comes back, so has the defect. - */ -const TRIAGE = [ - { file: 'scripts/check-bash32-floor.mjs', match: '8 findings, every one of them a false positive', - verdict: 'ILLUSTRATION', why: 'a counterfactual reading of the rule this gate replaced — the SIGN (all false positives) carries it, and the rule it measured is gone from this tree' }, - { file: 'scripts/check-cross-package-test-inputs.mjs', match: '6 exist only inside comments', - verdict: 'ILLUSTRATION', why: 'the load-bearing claim is the zero that follows it — none of the six escapes its package — and the corpus sizes only scale it' }, - { file: 'scripts/check-cross-package-test-inputs.mjs', match: '15 extensionless', - verdict: 'ILLUSTRATION', why: 'three spellings EXIST, which is why three branches exist; no branch is chosen by how many files carry it' }, - { file: 'scripts/check-cross-package-test-inputs.mjs', match: 'test files importing a workspace sibling by BARE specifier', - verdict: 'ILLUSTRATION', why: 'the ORDERING decides the design (bare specifiers dwarf relative ones), and no plausible drift reverses it' }, - { file: 'scripts/check-cross-package-test-inputs.mjs', match: '6 of the 60 declared globs are held by exactly such reads', - verdict: 'ILLUSTRATION', why: 'a non-emptiness claim — the blind spot is real — with a named specimen; nobody acts on how many' }, - { file: 'scripts/check-dispatcher-error-vocabulary.mjs', match: 'the naive `[{,]` anchor alone reports 19', - verdict: 'ILLUSTRATION', why: 'two recognisers compared at one moment; 19-against-13 is a relation, and the six extras are named individually' }, - { file: 'scripts/check-driver-conformance.mjs', match: "105 files under `driver-sql/src` carry a literal", - verdict: 'ILLUSTRATION', why: 'the claim is that those files are NOT defects — a class judgement; the count only says the class is large' }, - { file: 'scripts/check-examples-live-imports.mjs', match: '76 coupled files across 3 packages', - verdict: 'ILLUSTRATION', why: 'the refusal rests on the SHARE (a tiny coupled set inside a huge root), which no ordinary churn moves' }, - { file: 'scripts/check-logger-receiver-detach.mjs', match: 'over the property reads whose NAME is a log channel', - verdict: 'ILLUSTRATION', why: 'the section head one paragraph up names `--census` as reproducing every number in it — instrument-backed, one sentence further than T3 reaches' }, - { file: 'scripts/check-parse-guard.mjs', match: '9 of the 28 sites live in', - verdict: 'ILLUSTRATION', why: 'the finding is that the printed remedy is false for a subset that EXISTS; the subset is then described by kind, not by size' }, - { file: 'scripts/check-pnpm-filter-targets.mjs', match: 'Measured on this tree, the declaration names 235 tracked files', - verdict: 'REPAIRED', why: 'kind 3 — a declaration-honesty ratio over a root that grows with every script added, read as current state, reprinted by nothing' }, - { file: 'scripts/check-published-files.mjs', match: 'Measured on this tree: the declaration names 5263 tracked files', - verdict: 'REPAIRED', why: 'kind 3 — same shape over the whole publishable workspace, which moves on every package add or removal' }, - { file: 'scripts/check-release-section-coverage.mjs', match: 'assertion 1 fires on 24 minors', - verdict: 'ILLUSTRATION', why: 'the figures justify shipping WITH a baseline, and the ruling that fixes them is dated in the sentence before' }, - { file: 'scripts/check-sdui-lockstep.mjs', match: 'measured on this tree at 96 hint literals', - verdict: 'ILLUSTRATION', why: '"at 96 hint literals" is the CONDITION of the reading, not its subject: the claim is about which leading-dot literals the allowlist admits' }, - { file: 'scripts/check-skill-compatibility-version.mjs', match: 'both were measured on this tree', - verdict: 'REPAIRED', why: 'kind 3 — four precision ratios deciding which roots are declared, all moving with the tree and none reprinted' }, - { file: 'scripts/check-slot-lookup-ratchet.mjs', match: 'Measured on this tree: 46s', - verdict: 'REPAIRED', why: 'kind 3, and the worst-ageing kind: a duration is a reading of one BOX as much as of one tree, so it decays without the tree changing at all' }, - { file: 'scripts/check-test-source-alias.mjs', match: '1832 of the 4844 tracked files', - verdict: 'ILLUSTRATION', why: 'it records the state the fix REMOVED — the derivation no longer decides this gate by where a package sits — in a past-tense narrative T1 does not reach' }, - { file: 'scripts/check-where-matcher-conformance.mjs', match: '2889 of the 2889 files', - verdict: 'ILLUSTRATION', why: 'a set EQUALITY held in both directions by this gate own liveness-and-precision pin: it reds on drift rather than decaying' }, - { file: 'scripts/check-widget-option-census.mjs', match: 'inheritable literals: 0', - verdict: 'ILLUSTRATION', why: 'a table of zeros making an inheritance claim; the zeros are the claim' }, - { file: 'scripts/docs-audit/README.md', match: 'matched 82, 113, 43, 13 and 10 of 178 pages', - verdict: 'ILLUSTRATION', why: 'past-tense narrative of the first build guards — the words arrived as real declarations and matched; it describes a tree that is gone' }, - { file: 'scripts/docs-audit/README.md', match: 'named by 59 of 178 pages', - verdict: 'ILLUSTRATION', why: 'the argument is the 15% corpus SHARE rule stated two lines up; this is the share that condemned one term when the rule was written' }, - { file: 'scripts/docs-audit/README.md', match: 'reads the same 178-page corpus', - verdict: 'REPAIRED', why: 'kind 3, and the instance the card names: a present-tense corpus size that `check:docs-audit-scope` contradicts today' }, - { file: 'scripts/eslint-stack-headroom.mjs', match: 'the narrow scope is the worst case', - verdict: 'ILLUSTRATION', why: 'worst-case against intermittent — a comparison between two arms, and the gate is built on the comparison, not on either rate' }, - { file: 'scripts/git-merge-regen.mjs', match: 'measured on this tree: 18 rows', - verdict: 'ILLUSTRATION', why: 'the claim is that reverting #13585 loosening would turn the reconciliation RED; the row split only shows both branches are populated' }, - { file: 'scripts/pm/bare-root-worklist.mjs', match: 'reaches 2755 of ', - verdict: 'ILLUSTRATION', why: 'a set equality this file own --self-test re-derives and reds on, which is the whole point of a worklist that runs' }, - { file: 'scripts/pm/bare-root-worklist.mjs', match: 'numerator from the instrument own printed scan ', - verdict: 'ILLUSTRATION', why: 'the sentence names both instruments the two terms came from and refuses to carry either from the row above' }, - { file: 'scripts/pm/bare-root-worklist.mjs', match: 'the ratio does not change the verdict either direction', - verdict: 'ILLUSTRATION', why: 'the sentence says in its own words that the level is not acted on — a relation by declaration' }, -]; - -/* ──────────────────────────── the controls ─────────────────────────────── */ - -/** - * ⭐ BOTH DIRECTIONS, and neither one alone. A classifier only ever seen to - * fire proves nothing; one only ever seen to stay quiet proves less. - * - * Each control carries a `liveness` substring that must still be present in the - * file it was taken from. ⛔ A control quoting a sentence the tree no longer - * holds is a control that has stopped controlling anything, and it would pass - * in silence — the exact failure this whole file is about. - */ -const CONTROLS = [ - { - name: 'kind 1 — a citation carrying its revision, left alone', - file: 'scripts/check-tier-file-adoption.mjs', - liveness: 'Measured on this tree at d03c3c96d6', - sentence: 'Measured on this tree at d03c3c96d6, on the ONE package that owns tier files: ' - + '`packages/cli/vitest.config.ts` names `OS_TEST_TIERS` 5 times and `packages/cli/vitest-tiers.ts` 4 times.', - lead: '', next: '', - expect: 'KIND-1', - }, - { - name: 'kind 2 — a zero whose failure direction is named, left alone', - file: 'scripts/docs-audit/affected-docs.mjs', - liveness: 'zero commands declare `static topic`', - sentence: 'Measured on this tree: zero commands declare `static topic`, and the one `static id` ' - + "(`init.ts` -> `'init'`) agrees with its path, so nothing is mis-derived today.", - lead: '', - next: 'The failure direction if that ever changes is a phrase matching NO page — a recall miss the ' - + 'anchor list makes visible, not a false positive that pollutes the work list.', - expect: 'KIND-2', - }, - { - name: 'kind 3 — the card own named instance, CAUGHT (pre-repair text)', - file: 'scripts/docs-audit/README.md', - // ⛔ No liveness key: this is the sentence as it read BEFORE the repair, so - // the tree is required NOT to hold it. `absent` is checked instead. - absent: 'reads the same 178-page corpus', - sentence: '**Cost** (the card open question): the anchor derivation reads the same 178-page corpus ' - + 'the old one did, plus the 18 route-source/ledger files (~875 KB) and one `git show` per changed file per side.', - lead: 'How often it renders, re-derived over the 40 first-parent commits ending at `e43b18fd9`: ' - + '3 of the 17 package-touching runs (18%).', - next: 'Measured end-to-end on the ten PRs above, `node affected-docs.mjs` went from 85-195 ms to 114-582 ms.', - expect: 'REVIEW', - }, - { - name: 'kind 3 repaired — the SAME site now points at the gate that prints the value', - file: 'scripts/docs-audit/README.md', - liveness: 'check:docs-audit-scope', - sentence: '**Cost** (the card open question): the anchor derivation reads the same hand-written corpus ' - + 'the old one did — run `check-audit-scope.mjs` for today\'s page count rather than trusting one written ' - + 'down here — plus the 18 route-source/ledger files and one `git show` per changed file per side.', - lead: '', next: '', - expect: 'KIND-2', - }, - { - name: 'kind 2 — a refusal a live assertion holds, left alone', - file: 'scripts/check-skill-compatibility-version.mjs', - liveness: 'the refusal is pinned in the self-test', - sentence: 'So the manifest side stays undeclared, deliberately, and the refusal is pinned in the ' - + 'self-test rather than left in this paragraph — a later author who adds `packages/**` meets an assertion.', - lead: '', next: '', - expect: 'KIND-2', - }, - { - name: '⛔ the one-sentence anchor rule — a neighbour revision does NOT clear a bare magnitude', - file: 'scripts/pm/measurement-claim-triage.mjs', - sentence: 'The declaration names 235 tracked files under scripts/ and this gate reads 228 of them.', - lead: 'Re-derived over the 40 first-parent commits ending at `e43b18fd9`.', - next: '', - expect: 'REVIEW', - }, -]; - -/* ──────────────────────────────── report ───────────────────────────────── */ - -const KINDS = ['KIND-1', 'KIND-2', 'NO-FIGURE', 'REVIEW']; - -export function triageOf(hit) { - const flat = hit.sentence.replace(/\s+/g, ' '); - return TRIAGE.find((row) => row.file === hit.file && row.verdict !== 'REPAIRED' && flat.includes(row.match)) - ?? null; -} - -function verdicts() { - return collectPopulation().map((hit) => ({ hit, ...classify(hit.sentence, hit.lead, hit.next) })); -} - -export function report({ reviewOnly = false } = {}) { - const rows = verdicts(); - const counts = Object.fromEntries(KINDS.map((k) => [k, rows.filter((r) => r.kind === k).length])); - - console.log( - `measurement-claim triage: ${rows.length} claim(s) over ` - + `${new Set(rows.map((r) => r.hit.file)).size} file(s) under scripts/ — ` - + KINDS.map((k) => `${k} ${counts[k]}`).join(' · '), - ); - console.log( - ' ⛔ REVIEW is a CANDIDATE bucket, not a defect count — T4 (level or relation) is a judgement,\n' - + ' recorded per site in TRIAGE. ⛔ Reading any number above as a finding count is the error\n' - + ' this tool exists to prevent.', - ); - for (const e of EXCLUDED) console.log(` ⚠️ NOT SWEPT ${e.file} — ${e.why}`); - console.log(''); - - for (const row of rows) { - if (reviewOnly && row.kind !== 'REVIEW') continue; - const t = row.kind === 'REVIEW' ? triageOf(row.hit) : null; - const tag = row.kind === 'REVIEW' ? (t ? `T4 ${t.verdict}` : 'T4 UNTRIAGED') : row.kind; - console.log(` ${tag.padEnd(18)} ${row.hit.file}:${row.hit.line} [${row.cue}]`); - if (t) console.log(` ${' '.repeat(18)} ↳ ${t.why}`); - } - return rows; -} - -/* ─────────────────────────────── self-test ─────────────────────────────── */ - -const SELF_TEST_VERDICT = 'measurement-claim-triage self-test: controls hold in BOTH directions'; - -export function selfTest() { - const problems = []; - const rows = verdicts(); - - // #4690: an empty population refuses rather than printing a confident zero. - if (rows.length === 0) problems.push('PREREQUISITE NOT MET: the sweep found no claims at all under scripts/.'); - - for (const control of CONTROLS) { - const got = classify(control.sentence, control.lead, control.next).kind; - if (got !== control.expect) problems.push(`CONTROL "${control.name}": expected ${control.expect}, got ${got}.`); - let text = ''; - try { text = readFileSync(join(REPO_ROOT, control.file), 'utf8'); } catch { - problems.push(`CONTROL "${control.name}": ${control.file} is gone, so the control quotes nothing.`); - continue; - } - if (control.liveness && !text.includes(control.liveness)) { - problems.push( - `CONTROL "${control.name}": ${control.file} no longer carries \`${control.liveness}\`. ` - + 'A control quoting a sentence the tree has lost passes in silence — re-point it at a live one.', - ); - } - if (control.absent && text.includes(control.absent)) { - problems.push( - `CONTROL "${control.name}": ${control.file} carries \`${control.absent}\` again. ` - + 'That is the repaired kind-3 sentence restored — the defect is back.', - ); - } - } - - // Every REVIEW hit is judged by somebody. - for (const row of rows) { - if (row.kind !== 'REVIEW') continue; - if (!triageOf(row.hit)) { - problems.push( - `UNTRIAGED ${row.hit.file}:${row.hit.line} — a present-tense magnitude with no frame, no anchor ` - + 'and nothing loud, and no TRIAGE row judging it. Judge it (level or relation) and record why.', - ); - } - } - - // A TRIAGE row that matches nothing is a judgement about a tree that is gone. - for (const row of TRIAGE) { - const live = rows.some((r) => r.hit.file === row.file && r.hit.sentence.replace(/\s+/g, ' ').includes(row.match)); - if (row.verdict === 'ILLUSTRATION' && !live) { - problems.push(`STALE TRIAGE row ${row.file} "${row.match}" matches nothing in the population any more.`); - } - if (row.verdict === 'REPAIRED' && live) { - problems.push(`REGRESSED ${row.file} "${row.match}" is back in the population — the repair was undone.`); - } - } - - // ⛔ Every cue must still match something, or it is decoration. - for (const [cues, label] of [[FRAME_CUES, 'FRAME'], [ANCHOR_CUES, 'ANCHOR'], [LOUD_CUES, 'LOUD']]) { - for (const [re, name] of cues) { - const corpus = rows.map((r) => `${r.hit.lead}\n${r.hit.sentence}\n${r.hit.next}`.replace(/\s+/g, ' ')); - const controls = CONTROLS.map((c) => `${c.lead}\n${c.sentence}\n${c.next}`.replace(/\s+/g, ' ')); - if (![...corpus, ...controls].some((t) => re.test(t))) { - problems.push(`DEAD CUE ${label}/${name} ${re} matches nothing in the population or the controls.`); - } - } - } - - // This file's own prose must make no claim the sweep would have to judge. - const own = readFileSync(join(REPO_ROOT, SELF), 'utf8'); - const prose = own.slice(0, own.indexOf('const TRIAGE')); - FORM_A.lastIndex = 0; - if (FORM_A.test(prose)) { - problems.push('This file own prose now carries the swept phrase outside CONTROLS, and it excludes itself from the sweep.'); - } - - if (problems.length) { - console.error(`\n✗ measurement-claim-triage self-test: ${problems.length} problem(s)\n`); - for (const p of problems) console.error(` - ${p}`); - console.error(''); - process.exit(1); - } - console.log(`✓ ${SELF_TEST_VERDICT} (${CONTROLS.length} controls, ${TRIAGE.length} recorded judgements).`); - return SELF_TEST_VERDICT; -} - -if (isEntrypoint(import.meta.url)) { - if (process.argv.includes('--self-test')) { - if (selfTest() !== SELF_TEST_VERDICT) { - console.error('\n✗ measurement-claim-triage: selfTest() returned without reaching its verdict.\n'); - process.exit(1); - } - } else report({ reviewOnly: process.argv.includes('--review') }); -} From 21ddadb5f70966eb07942d2a812eacd427a6ba55 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:28:32 +0000 Subject: [PATCH 2/4] chore(scripts): --self-test for check-node-version, check-release-notes and check-service-providers, wired through their root aliases Each rule moves into a pure function over its inputs; main() keeps the tree read and prints byte-identical output (compared old vs new on a fixture tree, green and red paths). The self-tests follow AGENTS.md "Writing a --self-test": a literal row roster floored per label, the roster size pinned, and a verdict handshake the dispatch refuses to run without. The aliases CI already calls (pnpm check:X in lint.yml and release.yml) now run the self-test first, so check-self-test-wired sees each one run. Claude-Session: https://claude.ai/code/session_01LbdJFR3AmcKLZKQafYE1tN Co-authored-by: Claude --- package.json | 6 +- scripts/check-node-version.mjs | 489 +++++++++++++++++++--------- scripts/check-release-notes.mjs | 298 +++++++++++++---- scripts/check-service-providers.mjs | 291 ++++++++++++++--- 4 files changed, 827 insertions(+), 257 deletions(-) diff --git a/package.json b/package.json index 56529b33acd..0e69ffa350f 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "check:slot-lookup": "node scripts/check-slot-lookup-ratchet.mjs --self-test && node scripts/check-slot-lookup-ratchet.mjs", "check:query-options-erasure": "node scripts/check-query-options-erasure-ratchet.mjs --self-test && node scripts/check-query-options-erasure-ratchet.mjs", "check:verify-stand-in": "node scripts/check-verify-stand-in-erasure.mjs --self-test && node scripts/check-verify-stand-in-erasure.mjs", - "check:service-providers": "node scripts/check-service-providers.mjs", + "check:service-providers": "node scripts/check-service-providers.mjs --self-test && node scripts/check-service-providers.mjs", "check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs", "check:auth-mount-ledger": "node scripts/check-auth-mount-ledger.mjs --self-test && node scripts/check-auth-mount-ledger.mjs", "check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs", @@ -152,11 +152,11 @@ "check:override-consistency": "node scripts/check-override-consistency.mjs --self-test && node scripts/check-override-consistency.mjs", "check:vendor-export-contract": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs", "check:vendor-export-contract-resolve": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs --resolve", - "check:release-notes": "node scripts/check-release-notes.mjs", + "check:release-notes": "node scripts/check-release-notes.mjs --self-test && node scripts/check-release-notes.mjs", "check:release-page-status": "node scripts/check-release-page-status.mjs --self-test && node scripts/check-release-page-status.mjs", "check:release-body": "node scripts/release-github-releases.mjs --self-test", "check:release-index-currency-sync": "node scripts/sync-release-index-currency.mjs --self-test", - "check:node-version": "node scripts/check-node-version.mjs", + "check:node-version": "node scripts/check-node-version.mjs --self-test && node scripts/check-node-version.mjs", "check:pnpm-acquisition": "node scripts/check-pnpm-acquisition.mjs --self-test && node scripts/check-pnpm-acquisition.mjs", "check:workflow-status-functions": "node scripts/check-workflow-status-functions.mjs --self-test && node scripts/check-workflow-status-functions.mjs", "check:workflow-step-name-quoting": "node scripts/check-workflow-step-name-quoting.mjs --self-test && node scripts/check-workflow-step-name-quoting.mjs", diff --git a/scripts/check-node-version.mjs b/scripts/check-node-version.mjs index 42d8f2d6a7c..061b1d7fee0 100644 --- a/scripts/check-node-version.mjs +++ b/scripts/check-node-version.mjs @@ -24,6 +24,7 @@ // never ran. A green check that had stopped running the tests. // // node scripts/check-node-version.mjs +// node scripts/check-node-version.mjs --self-test # the rule against fixtures, no tree read // // .nvmrc is the single source of truth: it pins contributors' local runtime via // `nvm use` AND is what this guard holds every workflow to. Bumping Node is @@ -57,6 +58,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; const WORKFLOW_DIR = '.github/workflows'; const ACTION_DIR = '.github/actions'; @@ -64,18 +66,6 @@ const ACTION_DIR = '.github/actions'; const ACTION_FILES = ['action.yml', 'action.yaml']; const PIN_FILE = '.nvmrc'; -const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { - encoding: 'utf8', -}).trim(); - -// The pin, e.g. "22". Tolerates the "v22" and "lts/jod" forms nvm also accepts, -// but this repo writes the bare major -- that is what setup-node wants too. -const pin = readFileSync(join(root, PIN_FILE), 'utf8').trim(); -if (!pin) { - console.error(`check-node-version: ${PIN_FILE} is empty -- it must pin a Node major, e.g. 22.`); - process.exit(1); -} - // --- Lifecycle: the pin must not be a runtime Node has stopped patching. ------ // // Consistency alone does not make the pin correct. The guard below proves all @@ -103,76 +93,22 @@ const NODE_LIFECYCLE = { const WARN_WITHIN_DAYS = 180; const DAY = 24 * 60 * 60 * 1000; -const major = Number.parseInt(String(pin).replace(/^v/, ''), 10); -const lifecycle = NODE_LIFECYCLE[major]; - -if (!Number.isInteger(major) || !lifecycle) { - console.error( - `check-node-version: ${PIN_FILE} pins Node "${pin}", which this guard has no support dates for.\n\n` + - `Add it to NODE_LIFECYCLE in ${'scripts/check-node-version.mjs'} using the dates from\n` + - `https://github.com/nodejs/Release/blob/main/schedule.json, then re-run.\n` + - `Known: ${Object.keys(NODE_LIFECYCLE).join(', ')}.`, - ); - process.exit(1); +/** + * The lifecycle verdict for a pin on a given day. `kind` is what the self-test + * asserts; `main()` turns it into the same text this gate has always printed. + */ +function lifecycleVerdict(pin, today) { + if (!pin) return { kind: 'pin-empty' }; + const major = Number.parseInt(String(pin).replace(/^v/, ''), 10); + const lifecycle = NODE_LIFECYCLE[major]; + if (!Number.isInteger(major) || !lifecycle) return { kind: 'pin-unknown', major }; + const eol = new Date(`${lifecycle.end}T00:00:00Z`); + const daysLeft = Math.round((eol - today) / DAY); + const inMaintenance = today >= new Date(`${lifecycle.maintenance}T00:00:00Z`); + if (daysLeft <= 0) return { kind: 'pin-eol', major, lifecycle, daysLeft, inMaintenance }; + return { kind: daysLeft <= WARN_WITHIN_DAYS ? 'eol-near' : 'supported', major, lifecycle, daysLeft, inMaintenance }; } -const today = new Date(); -const eol = new Date(`${lifecycle.end}T00:00:00Z`); -const daysLeft = Math.round((eol - today) / DAY); -const inMaintenance = today >= new Date(`${lifecycle.maintenance}T00:00:00Z`); - -if (daysLeft <= 0) { - console.error( - `check-node-version: ${PIN_FILE} pins Node ${major}, which reached end-of-life on ${lifecycle.end} ` + - `(${Math.abs(daysLeft)} days ago).\n\n` + - `An EOL runtime receives no security patches, and every PR gate in this repo runs on it --\n` + - `so the runtime guarding each merge is the one nobody is fixing. That is #3825 verbatim.\n\n` + - `Bump ${PIN_FILE} to a supported major and update the workflows this guard then lists.\n` + - `Supported today: ${Object.entries(NODE_LIFECYCLE) - .filter(([, l]) => new Date(`${l.end}T00:00:00Z`) > today) - .map(([m, l]) => `${m} (until ${l.end})`) - .join(', ')}.`, - ); - process.exit(1); -} - -// GitHub renders ::warning:: in the job summary and on the PR, so this is -// visible without opening logs -- unlike a plain console.warn. -if (daysLeft <= WARN_WITHIN_DAYS) { - console.log( - `::warning file=${PIN_FILE}::Node ${major} reaches end-of-life on ${lifecycle.end} ` + - `(${daysLeft} days). Plan the bump before then -- when it lands, every PR gate will be ` + - `validating on an unpatched runtime. Bumping is a one-line ${PIN_FILE} edit plus the ` + - `workflows check-node-version lists.`, - ); -} - -const workflowFiles = readdirSync(join(root, WORKFLOW_DIR)) - .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) - .sort(); - -// Every `action.yml` / `action.yaml` under `.github/actions/`, walked rather -// than read one level deep because a local action may be nested -// (`uses: ./.github/actions/a/b`). An absent directory answers []. -function actionFilesUnder(dir, prefix = '') { - const out = []; - if (!existsSync(dir)) return out; - for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { - if (entry.isDirectory()) out.push(...actionFilesUnder(join(dir, entry.name), `${prefix}${entry.name}/`)); - else if (ACTION_FILES.includes(entry.name)) out.push(`${prefix}${entry.name}`); - } - return out; -} -const actionFiles = actionFilesUnder(join(root, ACTION_DIR)); - -// One list, each entry carrying the path it will be REPORTED under, so an -// offender names the file a reader can open rather than a name that is only -// unique inside one of the two roots. -const files = [ - ...workflowFiles.map((f) => ({ rel: `${WORKFLOW_DIR}/${f}`, abs: join(root, WORKFLOW_DIR, f) })), - ...actionFiles.map((f) => ({ rel: `${ACTION_DIR}/${f}`, abs: join(root, ACTION_DIR, f) })), -]; - // A step ends at the next YAML list item; `with:` keys live between the // `uses: actions/setup-node` line and that boundary. const SETUP_NODE = /^\s*(?:-\s+)?uses:\s*actions\/setup-node@/; @@ -182,97 +118,337 @@ const NODE_VERSION_FILE = /^\s*node-version-file:\s*(.+?)\s*$/; const unquote = (v) => v.replace(/^['"]|['"]$/g, '').trim(); -const offenders = []; -let steps = 0; - -for (const { rel: where, abs } of files) { - const lines = readFileSync(abs, 'utf8').split('\n'); - for (let i = 0; i < lines.length; i++) { - if (!SETUP_NODE.test(lines[i])) continue; - steps++; - - let found = null; - for (let j = i + 1; j < lines.length; j++) { - // Stop at the next step -- but not on the `- uses:` line we started from. - if (NEXT_ITEM.test(lines[j])) break; - const v = lines[j].match(NODE_VERSION); - if (v) { - found = { line: j + 1, kind: 'node-version', value: unquote(v[1]) }; - break; +/** + * Every `setup-node` step in `files` ({ rel, text }) held to `pin`. Each + * offender carries a `kind` beside its text, for the self-test to assert. + */ +function auditSetupNodeSteps(files, pin) { + const offenders = []; + let steps = 0; + + for (const { rel: where, text } of files) { + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (!SETUP_NODE.test(lines[i])) continue; + steps++; + + let found = null; + for (let j = i + 1; j < lines.length; j++) { + // Stop at the next step -- but not on the `- uses:` line we started from. + if (NEXT_ITEM.test(lines[j])) break; + const v = lines[j].match(NODE_VERSION); + if (v) { + found = { line: j + 1, kind: 'node-version', value: unquote(v[1]) }; + break; + } + const f = lines[j].match(NODE_VERSION_FILE); + if (f) { + found = { line: j + 1, kind: 'node-version-file', value: unquote(f[1]) }; + break; + } } - const f = lines[j].match(NODE_VERSION_FILE); - if (f) { - found = { line: j + 1, kind: 'node-version-file', value: unquote(f[1]) }; - break; + + if (!found) { + // No pin at all: the step silently inherits whatever Node the runner + // image ships, which GitHub bumps without telling us. + offenders.push({ + kind: 'no-version', + where: `${where}:${i + 1}`, + problem: 'declares no Node version -- inherits the runner default', + fix: `add "node-version: '${pin}'"`, + }); + continue; } - } - if (!found) { - // No pin at all: the step silently inherits whatever Node the runner - // image ships, which GitHub bumps without telling us. - offenders.push({ - where: `${where}:${i + 1}`, - problem: 'declares no Node version -- inherits the runner default', - fix: `add "node-version: '${pin}'"`, - }); - continue; - } + if (found.kind === 'node-version-file') { + // Pointing at the pin file is the ideal form; anything else is a second + // source of truth. + if (found.value.replace(/^\.\//, '') !== PIN_FILE) { + offenders.push({ + kind: 'other-version-file', + where: `${where}:${found.line}`, + problem: `reads its version from "${found.value}", not ${PIN_FILE}`, + fix: `use "node-version-file: ${PIN_FILE}"`, + }); + } + continue; + } - if (found.kind === 'node-version-file') { - // Pointing at the pin file is the ideal form; anything else is a second - // source of truth. - if (found.value.replace(/^\.\//, '') !== PIN_FILE) { + // A `${{ }}` expression (matrix input, env, workflow input) cannot be + // resolved from the file, so the guard cannot tell 22 from 20 here. Fail + // closed and say why, rather than either waving it through or reporting the + // raw expression as if it were a version number. A deliberate multi-version + // compatibility matrix is a real thing to want -- it just needs deciding + // out loud, since it is exactly the gates-vs-release split done on purpose. + if (found.value.includes('${{')) { offenders.push({ + kind: 'expression', where: `${where}:${found.line}`, - problem: `reads its version from "${found.value}", not ${PIN_FILE}`, - fix: `use "node-version-file: ${PIN_FILE}"`, + problem: `resolves its version from an expression (${found.value}) that this guard cannot evaluate`, + fix: `pin it literally as '${pin}', or extend this guard if a multi-version matrix is intended`, + }); + continue; + } + + if (found.value !== pin) { + offenders.push({ + kind: 'mismatch', + where: `${where}:${found.line}`, + problem: `pins Node ${found.value}, but ${PIN_FILE} says ${pin}`, + fix: `change it to '${pin}', or bump ${PIN_FILE} if the whole repo should move`, }); } - continue; } + } + return { steps, offenders }; +} + +// ── --self-test ────────────────────────────────────────────────────────────── +// +// Shape copied from `scripts/check-agent-model-declared.mjs` (AGENTS.md, +// "Writing a --self-test"): a literal cases table whose row LABELS are the +// battery roster, each floored at 1 and registered as the first statement of +// the loop body; the roster's own size pinned; and a verdict flag the dispatch +// refuses to run without. Fixtures only: the lifecycle rows pass their own +// `today`, so no row moves with the calendar. + +let selfTestReachedVerdict = false; + +const SELF_TEST_BATTERIES = Object.freeze({ + 'a step pinning the .nvmrc major → GREEN': 1, + 'a step reading node-version-file .nvmrc (with or without ./) → GREEN': 1, + 'a quoted node-version is unquoted before comparing → GREEN': 1, + 'a step pinning another major → RED (mismatch)': 1, + 'a step with no version → RED (inherits the runner default)': 1, + 'a step reading some other version file → RED': 1, + 'a step resolving its version from an expression → RED (fails closed)': 1, + 'the next step\'s version is never credited to a step that declares none → RED': 1, + 'offenders name file and line; every setup-node step is counted': 1, + 'a pin this gate has no dates for → RED (pin-unknown)': 1, + 'an empty pin → RED (pin-empty)': 1, + 'a pin past end-of-life → RED (pin-eol)': 1, + 'a pin inside the warning window → WARN, not RED': 1, + 'a pin far from end-of-life, written v-prefixed → GREEN': 1, +}); + +const SELF_TEST_BATTERY_FLOOR = 14; - // A `${{ }}` expression (matrix input, env, workflow input) cannot be - // resolved from the file, so the guard cannot tell 22 from 20 here. Fail - // closed and say why, rather than either waving it through or reporting the - // raw expression as if it were a version number. A deliberate multi-version - // compatibility matrix is a real thing to want -- it just needs deciding - // out loud, since it is exactly the gates-vs-release split done on purpose. - if (found.value.includes('${{')) { - offenders.push({ - where: `${where}:${found.line}`, - problem: `resolves its version from an expression (${found.value}) that this guard cannot evaluate`, - fix: `pin it literally as '${pin}', or extend this guard if a multi-version matrix is intended`, - }); - continue; +function selfTest() { + const step = (...withLines) => [' - uses: actions/setup-node@v4', ...(withLines.length ? [' with:', ...withLines.map((l) => ` ${l}`)] : [])]; + const workflow = (...stepLines) => ['jobs:', ' build:', ' steps:', ' - uses: actions/checkout@v4', ...stepLines.flat(), ' - run: pnpm test', ''].join('\n'); + const audit = (text) => auditSetupNodeSteps([{ rel: '.github/workflows/demo.yml', text }], '22'); + const kinds = (text) => audit(text).offenders.map((o) => o.kind); + const on = (iso) => new Date(`${iso}T00:00:00Z`); + + const cases = [ + { label: 'a step pinning the .nvmrc major → GREEN', got: () => kinds(workflow(step("node-version: 22"))), expect: [] }, + { + label: 'a step reading node-version-file .nvmrc (with or without ./) → GREEN', + got: () => kinds(workflow(step('node-version-file: .nvmrc'), step("node-version-file: './.nvmrc'"))), + expect: [], + }, + { label: 'a quoted node-version is unquoted before comparing → GREEN', got: () => kinds(workflow(step("node-version: '22'"), step('node-version: "22"'))), expect: [] }, + { label: 'a step pinning another major → RED (mismatch)', got: () => kinds(workflow(step("node-version: '20'"))), expect: ['mismatch'] }, + { label: 'a step with no version → RED (inherits the runner default)', got: () => kinds(workflow(step('cache: pnpm'))), expect: ['no-version'] }, + { label: 'a step reading some other version file → RED', got: () => kinds(workflow(step('node-version-file: package.json'))), expect: ['other-version-file'] }, + { + label: 'a step resolving its version from an expression → RED (fails closed)', + got: () => kinds(workflow(step('node-version: ${{ matrix.node }}'))), + expect: ['expression'], + }, + { + label: 'the next step\'s version is never credited to a step that declares none → RED', + got: () => kinds(workflow(step(), step('node-version: 22'))), + expect: ['no-version'], + }, + { + label: 'offenders name file and line; every setup-node step is counted', + got: () => { + const { steps, offenders } = audit(workflow(step('node-version: 22'), step('node-version: 20'))); + return [String(steps), ...offenders.map((o) => o.where)]; + }, + expect: ['2', '.github/workflows/demo.yml:10'], + }, + { label: 'a pin this gate has no dates for → RED (pin-unknown)', got: () => [lifecycleVerdict('19', on('2026-01-01')).kind, lifecycleVerdict('lts/jod', on('2026-01-01')).kind], expect: ['pin-unknown', 'pin-unknown'] }, + { label: 'an empty pin → RED (pin-empty)', got: () => [lifecycleVerdict('', on('2026-01-01')).kind], expect: ['pin-empty'] }, + { label: 'a pin past end-of-life → RED (pin-eol)', got: () => [lifecycleVerdict('20', on('2026-05-01')).kind, lifecycleVerdict('20', on('2026-04-30')).kind], expect: ['pin-eol', 'pin-eol'] }, + { label: 'a pin inside the warning window → WARN, not RED', got: () => [lifecycleVerdict('22', on('2027-01-01')).kind], expect: ['eol-near'] }, + { + label: 'a pin far from end-of-life, written v-prefixed → GREEN', + got: () => { + const v = lifecycleVerdict('v24', on('2026-01-01')); + return [v.kind, String(v.major), String(v.inMaintenance)]; + }, + expect: ['supported', '24', 'false'], + }, + ]; + + const batterySeen = new Map(); + let failed = 0; + for (const c of cases) { + batterySeen.set(c.label, (batterySeen.get(c.label) ?? 0) + 1); + const got = c.got(); + if (JSON.stringify(got) === JSON.stringify(c.expect)) { + console.log(` ✓ ${c.label}`); + } else { + console.error(` ✗ ${c.label}\n expected ${JSON.stringify(c.expect)}\n got ${JSON.stringify(got)}`); + failed += 1; } + } - if (found.value !== pin) { - offenders.push({ - where: `${where}:${found.line}`, - problem: `pins Node ${found.value}, but ${PIN_FILE} says ${pin}`, - fix: `change it to '${pin}', or bump ${PIN_FILE} if the whole repo should move`, - }); + // The floor: every declared row RAN, evaluated before the verdict so the + // success line can only follow a run whose registered rows equal the roster. + const floorFailure = (message) => { + console.error(`✗ self-test floor: ${message}`); + failed += 1; + }; + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floorFailure( + `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} -- ` + + 'a battery deleted from the roster takes its own floor with it.', + ); + } + const labels = cases.map((c) => c.label); + const duplicated = [...new Set(labels.filter((label, i) => labels.indexOf(label) !== i))]; + if (duplicated.length > 0) { + floorFailure(`the cases table uses ${duplicated.map((l) => JSON.stringify(l)).join(', ')} as a row label more than once.`); + } + for (const [label, count] of batterySeen) { + if (!declared.includes(label)) { + floorFailure(`self-test battery "${label}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES.`); } } + for (const label of declared) { + const count = batterySeen.get(label) ?? 0; + if (count < SELF_TEST_BATTERIES[label]) { + floorFailure(`self-test battery "${label}" DID NOT RUN -- ${count} case(s) registered, ${SELF_TEST_BATTERIES[label]} pinned.`); + } + } + + if (failed > 0) { + console.error(`\n✗ check-node-version self-test: ${failed} failure(s) (cases and floor).`); + process.exit(1); + } + console.log(`\n✓ check-node-version self-test: ${cases.length} cases pass.`); + selfTestReachedVerdict = true; } -if (offenders.length === 0) { - const phase = inMaintenance ? 'maintenance' : 'active LTS'; - console.log( - `check-node-version: OK (${steps} setup-node step(s) across ${workflowFiles.length} workflow(s) ` + - `and ${actionFiles.length} composite action(s), all on Node ${pin}).\n` + - ` Node ${major} is in ${phase}; supported until ${lifecycle.end} (${daysLeft} days).`, +function main() { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-node-version self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + return; + } + + const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', + }).trim(); + + // The pin, e.g. "22". Tolerates the "v22" and "lts/jod" forms nvm also accepts, + // but this repo writes the bare major -- that is what setup-node wants too. + const pin = readFileSync(join(root, PIN_FILE), 'utf8').trim(); + const today = new Date(); + const verdict = lifecycleVerdict(pin, today); + + if (verdict.kind === 'pin-empty') { + console.error(`check-node-version: ${PIN_FILE} is empty -- it must pin a Node major, e.g. 22.`); + process.exit(1); + } + + if (verdict.kind === 'pin-unknown') { + console.error( + `check-node-version: ${PIN_FILE} pins Node "${pin}", which this guard has no support dates for.\n\n` + + `Add it to NODE_LIFECYCLE in ${'scripts/check-node-version.mjs'} using the dates from\n` + + `https://github.com/nodejs/Release/blob/main/schedule.json, then re-run.\n` + + `Known: ${Object.keys(NODE_LIFECYCLE).join(', ')}.`, + ); + process.exit(1); + } + + const { major, lifecycle, daysLeft, inMaintenance } = verdict; + + if (verdict.kind === 'pin-eol') { + console.error( + `check-node-version: ${PIN_FILE} pins Node ${major}, which reached end-of-life on ${lifecycle.end} ` + + `(${Math.abs(daysLeft)} days ago).\n\n` + + `An EOL runtime receives no security patches, and every PR gate in this repo runs on it --\n` + + `so the runtime guarding each merge is the one nobody is fixing. That is #3825 verbatim.\n\n` + + `Bump ${PIN_FILE} to a supported major and update the workflows this guard then lists.\n` + + `Supported today: ${Object.entries(NODE_LIFECYCLE) + .filter(([, l]) => new Date(`${l.end}T00:00:00Z`) > today) + .map(([m, l]) => `${m} (until ${l.end})`) + .join(', ')}.`, + ); + process.exit(1); + } + + // GitHub renders ::warning:: in the job summary and on the PR, so this is + // visible without opening logs -- unlike a plain console.warn. + if (verdict.kind === 'eol-near') { + console.log( + `::warning file=${PIN_FILE}::Node ${major} reaches end-of-life on ${lifecycle.end} ` + + `(${daysLeft} days). Plan the bump before then -- when it lands, every PR gate will be ` + + `validating on an unpatched runtime. Bumping is a one-line ${PIN_FILE} edit plus the ` + + `workflows check-node-version lists.`, + ); + } + + const workflowFiles = readdirSync(join(root, WORKFLOW_DIR)) + .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) + .sort(); + + // Every `action.yml` / `action.yaml` under `.github/actions/`, walked rather + // than read one level deep because a local action may be nested + // (`uses: ./.github/actions/a/b`). An absent directory answers []. + function actionFilesUnder(dir, prefix = '') { + const out = []; + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isDirectory()) out.push(...actionFilesUnder(join(dir, entry.name), `${prefix}${entry.name}/`)); + else if (ACTION_FILES.includes(entry.name)) out.push(`${prefix}${entry.name}`); + } + return out; + } + const actionFiles = actionFilesUnder(join(root, ACTION_DIR)); + + // One list, each entry carrying the path it will be REPORTED under, so an + // offender names the file a reader can open rather than a name that is only + // unique inside one of the two roots. + const files = [ + ...workflowFiles.map((f) => ({ rel: `${WORKFLOW_DIR}/${f}`, abs: join(root, WORKFLOW_DIR, f) })), + ...actionFiles.map((f) => ({ rel: `${ACTION_DIR}/${f}`, abs: join(root, ACTION_DIR, f) })), + ]; + + const { steps, offenders } = auditSetupNodeSteps( + files.map(({ rel, abs }) => ({ rel, text: readFileSync(abs, 'utf8') })), + pin, ); - process.exit(0); -} -const plural = offenders.length === 1 ? 'step disagrees' : 'steps disagree'; -console.error(`check-node-version: ${offenders.length} setup-node ${plural} with ${PIN_FILE} (Node ${pin})\n`); -for (const o of offenders) { - console.error(` • ${o.where} -- ${o.problem}`); - console.error(` ${o.fix}`); -} -console.error(` + if (offenders.length === 0) { + const phase = inMaintenance ? 'maintenance' : 'active LTS'; + console.log( + `check-node-version: OK (${steps} setup-node step(s) across ${workflowFiles.length} workflow(s) ` + + `and ${actionFiles.length} composite action(s), all on Node ${pin}).\n` + + ` Node ${major} is in ${phase}; supported until ${lifecycle.end} (${daysLeft} days).`, + ); + process.exit(0); + } + + const plural = offenders.length === 1 ? 'step disagrees' : 'steps disagree'; + console.error(`check-node-version: ${offenders.length} setup-node ${plural} with ${PIN_FILE} (Node ${pin})\n`); + for (const o of offenders) { + console.error(` • ${o.where} -- ${o.problem}`); + console.error(` ${o.fix}`); + } + console.error(` Every workflow must run the Node version in ${PIN_FILE}, so that what CI verifies is what release publishes from. When those drift apart, PR gates validate code on a runtime nothing ships from -- and a dependency that needs the @@ -282,4 +458,9 @@ suite with silently missing cases (#3812). To move the whole repo to a new Node version, edit ${PIN_FILE} and then update every step this guard lists. The census covers ${WORKFLOW_DIR}/ and ${ACTION_DIR}/ alike -- a setup-node step is a Node pin wherever it is written.`); -process.exit(1); + process.exit(1); +} + +if (isEntrypoint(import.meta.url)) { + main(); +} diff --git a/scripts/check-release-notes.mjs b/scripts/check-release-notes.mjs index 433a05f5650..7f2d44aff44 100644 --- a/scripts/check-release-notes.mjs +++ b/scripts/check-release-notes.mjs @@ -21,11 +21,13 @@ // else missing is a real gap and fails the build. // // node scripts/check-release-notes.mjs +// node scripts/check-release-notes.mjs --self-test # the rule against fixtures, no tree read // // When a new major ships: write content/docs/releases/v.mdx (lead with // breaking changes + migration; fold minors into a "What's new in N.x" section) // and add it to content/docs/releases/meta.json. import { readFileSync, existsSync } from 'node:fs'; +import { isEntrypoint } from './invoked-as.mjs'; const SPEC_CHANGELOG = 'packages/spec/CHANGELOG.md'; const RELEASES_DIR = 'content/docs/releases'; @@ -38,8 +40,7 @@ const DOCS_HOME = 'content/docs/index.mdx'; const FLOOR_MAJOR = 9; const KNOWN_MISSING = new Set([10, 11]); -function releasedMajors() { - const text = readFileSync(SPEC_CHANGELOG, 'utf8'); +function releasedMajors(text) { const majors = new Set(); for (const m of text.matchAll(/^##\s+(\d+)\.\d+\.\d+/gm)) { majors.add(Number.parseInt(m[1], 10)); @@ -47,66 +48,251 @@ function releasedMajors() { return [...majors].sort((a, b) => a - b); } -const meta = JSON.parse(readFileSync(META_PATH, 'utf8')); -const metaPages = new Set(meta.pages || []); - -const problems = []; - -// The section itself has to be REACHABLE, or every page inside it is orphaned no -// matter how well the section's own meta.json is wired. #3423 took `releases` -// out of the root sidebar deliberately ("lookup content ... the docs home page -// links both"), which is a legitimate IA choice — the failure mode is losing -// BOTH entry points, which this check catches while allowing either one. -// Until now the success message claimed "navigable" while only ever reading the -// section's own meta.json. -const inRootNav = (() => { - try { - return (JSON.parse(readFileSync(ROOT_META_PATH, 'utf8')).pages || []).includes('releases'); - } catch { - return false; +/** + * The whole rule, over inputs `main()` reads from the tree and `selfTest()` + * builds by hand. `inRootNav` / `docsHome` are null when their file could not + * be read or parsed, which counts as "no entry point", exactly as the tree read + * always has. Each problem carries a `kind` and the `subject` it names, so the + * self-test asserts WHAT was found, never the wording of the message. + */ +function findProblems({ changelog, metaPages, inRootNav, docsHome, pageExists }) { + const problems = []; + + // The section itself has to be REACHABLE, or every page inside it is orphaned no + // matter how well the section's own meta.json is wired. #3423 took `releases` + // out of the root sidebar deliberately ("lookup content ... the docs home page + // links both"), which is a legitimate IA choice — the failure mode is losing + // BOTH entry points, which this check catches while allowing either one. + // Until now the success message claimed "navigable" while only ever reading the + // section's own meta.json. + const onDocsHome = docsHome !== null && /\]\(\/docs\/releases\b/.test(docsHome); + if (inRootNav !== true && !onDocsHome) { + problems.push({ + kind: 'section-unreachable', + subject: 'releases', + text: + `the Releases section is unreachable — "releases" is not in ${ROOT_META_PATH} ` + + `"pages" AND ${DOCS_HOME} carries no /docs/releases link. Every release page ` + + `is then an orphan that only a direct URL can reach. Restore one of the two.`, + }); } -})(); -const onDocsHome = (() => { - try { - return /\]\(\/docs\/releases\b/.test(readFileSync(DOCS_HOME, 'utf8')); - } catch { - return false; + for (const major of releasedMajors(changelog)) { + if (major < FLOOR_MAJOR || KNOWN_MISSING.has(major)) continue; + const slug = `v${major}`; + // Two layouts, both first-class, and this gate owns EXISTENCE only: one flat + // `v16.mdx`, or a `v17/` folder whose `index.mdx` is the major's landing page + // with one file per minor beside it. Either satisfies "the major has a + // curated, navigable page" — the nav entry is the folder name in both cases, + // so the `metaPages` check below is unchanged. ⛔ Neither layout is deprecated. + if (!pageExists(`${RELEASES_DIR}/${slug}.mdx`) && !pageExists(`${RELEASES_DIR}/${slug}/index.mdx`)) { + problems.push({ + kind: 'page-missing', + subject: slug, + text: + `${RELEASES_DIR}/${slug}.mdx (or ${RELEASES_DIR}/${slug}/index.mdx) is missing — ` + + `@objectstack/spec shipped a ${major}.x ` + + `release but there is no curated release page. Write it (lead with breaking ` + + `changes + migration), then add "${slug}" to ${META_PATH}.`, + }); + } else if (!metaPages.has(slug)) { + problems.push({ + kind: 'page-unlisted', + subject: slug, + text: + `${slug}.mdx exists but is not listed in ${META_PATH} "pages" — the page is ` + + `unreachable in the docs nav. Add "${slug}" to the pages array.`, + }); + } } -})(); -if (!inRootNav && !onDocsHome) { - problems.push( - `the Releases section is unreachable — "releases" is not in ${ROOT_META_PATH} ` + - `"pages" AND ${DOCS_HOME} carries no /docs/releases link. Every release page ` + - `is then an orphan that only a direct URL can reach. Restore one of the two.`, - ); + return problems; } -for (const major of releasedMajors()) { - if (major < FLOOR_MAJOR || KNOWN_MISSING.has(major)) continue; - const slug = `v${major}`; - // Two layouts, both first-class, and this gate owns EXISTENCE only: one flat - // `v16.mdx`, or a `v17/` folder whose `index.mdx` is the major's landing page - // with one file per minor beside it. Either satisfies "the major has a - // curated, navigable page" — the nav entry is the folder name in both cases, - // so the `metaPages` check below is unchanged. ⛔ Neither layout is deprecated. - if (!existsSync(`${RELEASES_DIR}/${slug}.mdx`) && !existsSync(`${RELEASES_DIR}/${slug}/index.mdx`)) { - problems.push( - `${RELEASES_DIR}/${slug}.mdx (or ${RELEASES_DIR}/${slug}/index.mdx) is missing — ` + - `@objectstack/spec shipped a ${major}.x ` + - `release but there is no curated release page. Write it (lead with breaking ` + - `changes + migration), then add "${slug}" to ${META_PATH}.`, - ); - } else if (!metaPages.has(slug)) { - problems.push( - `${slug}.mdx exists but is not listed in ${META_PATH} "pages" — the page is ` + - `unreachable in the docs nav. Add "${slug}" to the pages array.`, + +// ── --self-test ────────────────────────────────────────────────────────────── +// +// Shape copied from `scripts/check-agent-model-declared.mjs` (AGENTS.md, +// "Writing a --self-test"): a literal cases table whose row LABELS are the +// battery roster, each floored at 1 and registered as the first statement of +// the loop body; the roster's own size pinned; and a verdict flag the dispatch +// refuses to run without. + +let selfTestReachedVerdict = false; + +const SELF_TEST_BATTERIES = Object.freeze({ + 'every released major has a page listed in meta.json → GREEN': 1, + 'a released major with no page → RED, naming the missing page': 1, + 'a page that exists but is not in meta.json "pages" → RED': 1, + 'the folder layout (vN/index.mdx) satisfies existence → GREEN': 1, + 'majors below the floor and the known-missing v10/v11 are skipped → GREEN': 1, + 'majors are read from CHANGELOG release headings only, deduplicated': 1, + 'no root-nav entry AND no docs-home link → RED (section unreachable)': 1, + 'the docs-home link alone keeps the section reachable → GREEN': 1, + 'an unreadable root meta.json and docs home count as no entry point → RED': 1, + 'several defects → every one reported, not only the first': 1, +}); + +const SELF_TEST_BATTERY_FLOOR = 10; + +function selfTest() { + const page = (slug) => `${RELEASES_DIR}/${slug}.mdx`; + const changelog = (...versions) => `# @objectstack/spec\n\n${versions.map((v) => `## ${v}\n\n- a change\n`).join('\n')}`; + const tree = ({ log = changelog('16.0.0', '17.2.0', '17.0.0'), meta = ['v16', 'v17'], pages = [page('v16'), page('v17')], inRootNav = true, docsHome = null } = {}) => ({ + changelog: log, + metaPages: new Set(meta), + inRootNav, + docsHome, + pageExists: (path) => pages.includes(path), + }); + + const cases = [ + { + label: 'every released major has a page listed in meta.json → GREEN', + input: tree(), + expect: [], + }, + { + label: 'a released major with no page → RED, naming the missing page', + input: tree({ pages: [page('v16')] }), + expect: ['page-missing:v17'], + }, + { + label: 'a page that exists but is not in meta.json "pages" → RED', + input: tree({ meta: ['v16'] }), + expect: ['page-unlisted:v17'], + }, + { + label: 'the folder layout (vN/index.mdx) satisfies existence → GREEN', + input: tree({ pages: [page('v16'), `${RELEASES_DIR}/v17/index.mdx`] }), + expect: [], + }, + { + label: 'majors below the floor and the known-missing v10/v11 are skipped → GREEN', + input: tree({ log: changelog('8.1.0', '10.0.0', '11.3.2', '16.0.0'), meta: ['v16'], pages: [page('v16')] }), + expect: [], + }, + { + label: 'majors are read from CHANGELOG release headings only, deduplicated', + check: () => JSON.stringify(releasedMajors('## 13.0.0\n### 99.0.0\n## 12.1.0\n## 13.4.1\n ## 14.0.0\n## 15.x\n')) === '[12,13]', + }, + { + label: 'no root-nav entry AND no docs-home link → RED (section unreachable)', + input: tree({ inRootNav: false, docsHome: 'See [the changelog](/docs/changelog).' }), + expect: ['section-unreachable:releases'], + }, + { + label: 'the docs-home link alone keeps the section reachable → GREEN', + input: tree({ inRootNav: false, docsHome: 'Read [the release notes](/docs/releases).' }), + expect: [], + }, + { + label: 'an unreadable root meta.json and docs home count as no entry point → RED', + input: tree({ inRootNav: null, docsHome: null }), + expect: ['section-unreachable:releases'], + }, + { + label: 'several defects → every one reported, not only the first', + input: tree({ inRootNav: false, meta: [], pages: [page('v16')] }), + expect: ['section-unreachable:releases', 'page-unlisted:v16', 'page-missing:v17'], + }, + ]; + + const batterySeen = new Map(); + let failed = 0; + for (const c of cases) { + batterySeen.set(c.label, (batterySeen.get(c.label) ?? 0) + 1); + let ok; + let detail = ''; + if (c.check) { + ok = c.check(); + } else { + const got = findProblems(c.input).map((p) => `${p.kind}:${p.subject}`); + ok = JSON.stringify(got) === JSON.stringify(c.expect); + if (!ok) detail = `\n expected ${JSON.stringify(c.expect)}\n got ${JSON.stringify(got)}`; + } + if (ok) { + console.log(` ✓ ${c.label}`); + } else { + console.error(` ✗ ${c.label}${detail}`); + failed += 1; + } + } + + // The floor: every declared row RAN, evaluated before the verdict so the + // success line can only follow a run whose registered rows equal the roster. + const floorFailure = (message) => { + console.error(`✗ self-test floor: ${message}`); + failed += 1; + }; + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floorFailure( + `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — ` + + 'a battery deleted from the roster takes its own floor with it.', ); } + const labels = cases.map((c) => c.label); + const duplicated = [...new Set(labels.filter((label, i) => labels.indexOf(label) !== i))]; + if (duplicated.length > 0) { + floorFailure(`the cases table uses ${duplicated.map((l) => JSON.stringify(l)).join(', ')} as a row label more than once.`); + } + for (const [label, count] of batterySeen) { + if (!declared.includes(label)) { + floorFailure(`self-test battery "${label}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES.`); + } + } + for (const label of declared) { + const count = batterySeen.get(label) ?? 0; + if (count < SELF_TEST_BATTERIES[label]) { + floorFailure(`self-test battery "${label}" DID NOT RUN — ${count} case(s) registered, ${SELF_TEST_BATTERIES[label]} pinned.`); + } + } + + if (failed > 0) { + console.error(`\n✗ check-release-notes self-test: ${failed} failure(s) (cases and floor).`); + process.exit(1); + } + console.log(`\n✓ check-release-notes self-test: ${cases.length} cases pass.`); + selfTestReachedVerdict = true; } -if (problems.length > 0) { - console.error(`check-release-notes: ${problems.length} problem(s)\n`); - for (const p of problems) console.error(` • ${p}`); - process.exit(1); +function main() { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-release-notes self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + return; + } + + const meta = JSON.parse(readFileSync(META_PATH, 'utf8')); + const readOrNull = (path, parse) => { + try { + return parse(readFileSync(path, 'utf8')); + } catch { + return null; + } + }; + const problems = findProblems({ + changelog: readFileSync(SPEC_CHANGELOG, 'utf8'), + metaPages: new Set(meta.pages || []), + inRootNav: readOrNull(ROOT_META_PATH, (text) => (JSON.parse(text).pages || []).includes('releases')), + docsHome: readOrNull(DOCS_HOME, (text) => text), + pageExists: existsSync, + }); + + if (problems.length > 0) { + console.error(`check-release-notes: ${problems.length} problem(s)\n`); + for (const p of problems) console.error(` • ${p.text}`); + process.exit(1); + } + + console.log('check-release-notes: OK — every released major has a curated, navigable release page.'); } -console.log('check-release-notes: OK — every released major has a curated, navigable release page.'); +if (isEntrypoint(import.meta.url)) { + main(); +} diff --git a/scripts/check-service-providers.mjs b/scripts/check-service-providers.mjs index d13fbcae00c..be955d97e09 100644 --- a/scripts/check-service-providers.mjs +++ b/scripts/check-service-providers.mjs @@ -30,9 +30,13 @@ // cannot tell those apart — `REMEDY_DETAIL` in the same file is where the // second kind gets an accurate sentence, and the reason a bare `null` must not // be read here (or reported) as "nothing exists". +// +// node scripts/check-service-providers.mjs +// node scripts/check-service-providers.mjs --self-test # the rule against fixtures import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join, sep } from 'node:path'; +import { isEntrypoint } from './invoked-as.mjs'; const ROOT = process.cwd(); const TABLE_FILE = 'packages/spec/src/system/core-services.zod.ts'; @@ -146,56 +150,255 @@ function coreServiceNames(src) { return [...src.slice(start, end).matchAll(/'([a-z0-9-]+)'/g)].map((m) => m[1]); } -const src = readFileSync(join(ROOT, TABLE_FILE), 'utf8'); -const table = parseTable(src); -const slots = coreServiceNames(src); -const packages = workspacePackageNames(); - -const problems = []; - -for (const [slot, pkg] of table) { - if (pkg === null) continue; - if (!packages.has(pkg)) { - problems.push( - ` ${slot} → ${pkg}\n` - + ' Not a workspace package. Point it at the package that actually registers\n' - + ` the '${slot}' slot, or use \`null\` if no installable package provides it\n` - + ' (adding a REMEDY_DETAIL sentence if something ships that simply cannot\n' - + ' be installed, e.g. a private package in another repository).', - ); - } -} - // `data` and `metadata` are filled by the engine itself (ObjectQL / the // protocol implementation), never by an installable optional package, so they // carry no remedy and need no entry. const NO_REMEDY_SLOTS = new Set(['data', 'metadata']); -for (const slot of slots) { - if (NO_REMEDY_SLOTS.has(slot) || table.has(slot)) continue; - problems.push( - ` ${slot} → (missing)\n` - + ' Declared in CoreServiceName but absent from CORE_SERVICE_PROVIDER, so\n' - + ' discovery would report it unavailable with a remedy naming nothing.', - ); + +/** + * The rule over its three inputs, which `main()` reads from the tree and + * `selfTest()` builds by hand. Each problem carries a `kind` and the slot it + * names, so the self-test asserts WHAT was found, never the message wording. + */ +function findProblems({ table, slots, packages }) { + const problems = []; + + for (const [slot, pkg] of table) { + if (pkg === null) continue; + if (!packages.has(pkg)) { + problems.push({ + kind: 'not-a-workspace-package', + slot, + text: + ` ${slot} → ${pkg}\n` + + ' Not a workspace package. Point it at the package that actually registers\n' + + ` the '${slot}' slot, or use \`null\` if no installable package provides it\n` + + ' (adding a REMEDY_DETAIL sentence if something ships that simply cannot\n' + + ' be installed, e.g. a private package in another repository).', + }); + } + } + + for (const slot of slots) { + if (NO_REMEDY_SLOTS.has(slot) || table.has(slot)) continue; + problems.push({ + kind: 'slot-missing', + slot, + text: + ` ${slot} → (missing)\n` + + ' Declared in CoreServiceName but absent from CORE_SERVICE_PROVIDER, so\n' + + ' discovery would report it unavailable with a remedy naming nothing.', + }); + } + return problems; } -if (problems.length > 0) { - console.error('✗ Service-provider remedies (#4093 follow-up)\n'); - console.error(problems.join('\n\n')); - console.error( - '\nDiscovery reports these as `provider` and as "Install X to enable". A name\n' - + `that cannot be installed is worse than none. See ${TABLE_FILE}.`, +// ── --self-test ────────────────────────────────────────────────────────────── +// +// Shape copied from `scripts/check-agent-model-declared.mjs` (AGENTS.md, +// "Writing a --self-test"): a literal cases table whose row LABELS are the +// battery roster, each floored at 1 and registered as the first statement of +// the loop body; the roster's own size pinned; and a verdict flag the dispatch +// refuses to run without. + +let selfTestReachedVerdict = false; + +const SELF_TEST_BATTERIES = Object.freeze({ + 'every named provider is a workspace package, every slot has an entry → GREEN': 1, + 'a provider name that is not a workspace package → RED, naming the slot': 1, + 'a CoreServiceName slot absent from the table → RED, naming the slot': 1, + '`null` is a legitimate entry and is never looked up → GREEN': 1, + '`data` and `metadata` need no entry → GREEN': 1, + 'several defects → every one reported, not only the first': 1, + 'the table parser reads quoted and null entries, and nothing outside the table': 1, + 'a source without CORE_SERVICE_PROVIDER → the parser throws, never an empty table': 1, + 'a table not closed with `} as const;` → the parser throws': 1, + 'CoreServiceName is read from its z.enum declaration': 1, + 'the live table parses to a non-empty table and slot list (the read is not vacuous)': 1, +}); + +const SELF_TEST_BATTERY_FLOOR = 11; + +function selfTest() { + const source = (rows, enumSlots) => + `export const CoreServiceName = z.enum([\n${enumSlots.map((s) => ` '${s}',`).join('\n')}\n]);\n\n` + + `export const CORE_SERVICE_PROVIDER = {\n${rows.join('\n')}\n} as const;\n`; + const input = ({ table = [['cache', '@demo/cache'], ['search', null]], slots = ['data', 'metadata', 'cache', 'search'], packages = ['@demo/cache'] } = {}) => ({ + table: new Map(table), + slots, + packages: new Set(packages), + }); + const throws = (fn) => { + try { + fn(); + return false; + } catch { + return true; + } + }; + + const cases = [ + { + label: 'every named provider is a workspace package, every slot has an entry → GREEN', + input: input(), + expect: [], + }, + { + label: 'a provider name that is not a workspace package → RED, naming the slot', + input: input({ packages: ['@demo/other'] }), + expect: ['not-a-workspace-package:cache'], + }, + { + label: 'a CoreServiceName slot absent from the table → RED, naming the slot', + input: input({ slots: ['data', 'metadata', 'cache', 'search', 'queue'] }), + expect: ['slot-missing:queue'], + }, + { + label: '`null` is a legitimate entry and is never looked up → GREEN', + input: input({ table: [['cache', '@demo/cache'], ['search', null]], packages: ['@demo/cache'] }), + expect: [], + }, + { + label: '`data` and `metadata` need no entry → GREEN', + input: input({ table: [], slots: ['data', 'metadata'], packages: [] }), + expect: [], + }, + { + label: 'several defects → every one reported, not only the first', + input: input({ table: [['cache', '@demo/typo'], ['search', null]], slots: ['cache', 'search', 'queue'], packages: [] }), + expect: ['not-a-workspace-package:cache', 'slot-missing:queue'], + }, + { + label: 'the table parser reads quoted and null entries, and nothing outside the table', + check: () => { + const src = `const OTHER = { 'outside': '@demo/outside', };\n${source([ + " // a comment line that names no entry", + " 'cache': '@demo/cache',", + " 'search': null,", + ], ['cache'])}const AFTER = { 'late': '@demo/late', };\n`; + return JSON.stringify([...parseTable(src)]) === JSON.stringify([['cache', '@demo/cache'], ['search', null]]); + }, + }, + { + label: 'a source without CORE_SERVICE_PROVIDER → the parser throws, never an empty table', + check: () => throws(() => parseTable("export const CoreServiceName = z.enum(['cache']);\n")), + }, + { + label: 'a table not closed with `} as const;` → the parser throws', + check: () => throws(() => parseTable("export const CORE_SERVICE_PROVIDER = {\n 'cache': '@demo/cache',\n};\n")), + }, + { + label: 'CoreServiceName is read from its z.enum declaration', + check: () => JSON.stringify(coreServiceNames(source([], ['data', 'cache', 'job-queue']))) === JSON.stringify(['data', 'cache', 'job-queue']), + }, + { + label: 'the live table parses to a non-empty table and slot list (the read is not vacuous)', + check: () => { + const live = readFileSync(join(ROOT, TABLE_FILE), 'utf8'); + return parseTable(live).size > 0 && coreServiceNames(live).length > 0; + }, + }, + ]; + + const batterySeen = new Map(); + let failed = 0; + for (const c of cases) { + batterySeen.set(c.label, (batterySeen.get(c.label) ?? 0) + 1); + let ok; + let detail = ''; + if (c.check) { + ok = c.check(); + } else { + const got = findProblems(c.input).map((p) => `${p.kind}:${p.slot}`); + ok = JSON.stringify(got) === JSON.stringify(c.expect); + if (!ok) detail = `\n expected ${JSON.stringify(c.expect)}\n got ${JSON.stringify(got)}`; + } + if (ok) { + console.log(` ✓ ${c.label}`); + } else { + console.error(` ✗ ${c.label}${detail}`); + failed += 1; + } + } + + // The floor: every declared row RAN, evaluated before the verdict so the + // success line can only follow a run whose registered rows equal the roster. + const floorFailure = (message) => { + console.error(`✗ self-test floor: ${message}`); + failed += 1; + }; + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floorFailure( + `SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — ` + + 'a battery deleted from the roster takes its own floor with it.', + ); + } + const labels = cases.map((c) => c.label); + const duplicated = [...new Set(labels.filter((label, i) => labels.indexOf(label) !== i))]; + if (duplicated.length > 0) { + floorFailure(`the cases table uses ${duplicated.map((l) => JSON.stringify(l)).join(', ')} as a row label more than once.`); + } + for (const [label, count] of batterySeen) { + if (!declared.includes(label)) { + floorFailure(`self-test battery "${label}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES.`); + } + } + for (const label of declared) { + const count = batterySeen.get(label) ?? 0; + if (count < SELF_TEST_BATTERIES[label]) { + floorFailure(`self-test battery "${label}" DID NOT RUN — ${count} case(s) registered, ${SELF_TEST_BATTERIES[label]} pinned.`); + } + } + + if (failed > 0) { + console.error(`\n✗ check-service-providers self-test: ${failed} failure(s) (cases and floor).`); + process.exit(1); + } + console.log(`\n✓ check-service-providers self-test: ${cases.length} cases pass.`); + selfTestReachedVerdict = true; +} + +function main() { + if (process.argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-service-providers self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + return; + } + + const src = readFileSync(join(ROOT, TABLE_FILE), 'utf8'); + const table = parseTable(src); + const problems = findProblems({ table, slots: coreServiceNames(src), packages: workspacePackageNames() }); + + if (problems.length > 0) { + console.error('✗ Service-provider remedies (#4093 follow-up)\n'); + console.error(problems.map((p) => p.text).join('\n\n')); + console.error( + '\nDiscovery reports these as `provider` and as "Install X to enable". A name\n' + + `that cannot be installed is worse than none. See ${TABLE_FILE}.`, + ); + process.exit(1); + } + + const named = [...table.values()].filter(Boolean).length; + // Deliberately NOT "nothing ships yet" — `null` means "no name belongs in an + // `Install X` sentence", which also covers a provider that exists but cannot be + // installed (`ai`, whose `@objectstack/service-ai` is private to the cloud + // repo). Saying "nothing ships" here would repeat, in this script's own output, + // the conflation the table was corrected to stop making. + console.log( + `✓ check:service-providers — ${table.size} slot(s): ${named} name an installable workspace ` + + `package, ${table.size - named} name none (see REMEDY_DETAIL for those that still ship something).`, ); - process.exit(1); } -const named = [...table.values()].filter(Boolean).length; -// Deliberately NOT "nothing ships yet" — `null` means "no name belongs in an -// `Install X` sentence", which also covers a provider that exists but cannot be -// installed (`ai`, whose `@objectstack/service-ai` is private to the cloud -// repo). Saying "nothing ships" here would repeat, in this script's own output, -// the conflation the table was corrected to stop making. -console.log( - `✓ check:service-providers — ${table.size} slot(s): ${named} name an installable workspace ` - + `package, ${table.size - named} name none (see REMEDY_DETAIL for those that still ship something).`, -); +if (isEntrypoint(import.meta.url)) { + main(); +} From 2e05d6c9aff350505cd116dc900072f508605a95 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:03:21 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore(scripts):=20keep=20measure-partial-re?= =?UTF-8?q?tirement-annotation.mjs=20=E2=80=94=20the=20dispatch-gates=20sl?= =?UTF-8?q?ow=20tier=20derives=20it=20as=20its=20seam=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slow tier of check:pm-dispatch-gates (run on push to main and on the schedule, not on a PR) spawns the CLI against this path and reds 16 cases when it is absent. That is a real caller, so the script and its two mentions go back to their state at 7111e14ac9. Claude-Session: https://claude.ai/code/session_01LbdJFR3AmcKLZKQafYE1tN Co-authored-by: Claude --- ...08-partial-retirement-annotation-signal.md | 6 +- scripts/measure-durability-swallow-family.mjs | 2 +- .../measure-partial-retirement-annotation.mjs | 534 ++++++++++++++++++ 3 files changed, 536 insertions(+), 6 deletions(-) create mode 100644 scripts/measure-partial-retirement-annotation.mjs diff --git a/docs/audits/2026-08-partial-retirement-annotation-signal.md b/docs/audits/2026-08-partial-retirement-annotation-signal.md index 4b992af71d7..1c46f280d8e 100644 --- a/docs/audits/2026-08-partial-retirement-annotation-signal.md +++ b/docs/audits/2026-08-partial-retirement-annotation-signal.md @@ -5,8 +5,7 @@ **Instrument**: `scripts/measure-partial-retirement-annotation.mjs`. Not a gate, not wired into any workflow, not a `check:`/`gen:` script. Committed so the numbers are -reproducible rather than asserted; later removed from the tree and held at commit -`7111e14ac9`. +reproducible rather than asserted. **Mandate**: the maintainer ruling of 2026-08-09 on #6635 — measurement first, no gate is built yet. This document reports total hits, a spot-verified true/false-positive @@ -155,9 +154,6 @@ explicitly so it can be audited. ## 8. Reproducing -The instrument was removed from the tree; run it from a checkout of commit `7111e14ac9`, -which holds it (`git worktree add --detach ../objectstack-7111e14ac9 7111e14ac9`). - ```bash node scripts/measure-partial-retirement-annotation.mjs # summary node scripts/measure-partial-retirement-annotation.mjs --hits # every hit with context diff --git a/scripts/measure-durability-swallow-family.mjs b/scripts/measure-durability-swallow-family.mjs index c33287d068f..e8753b5ebb8 100644 --- a/scripts/measure-durability-swallow-family.mjs +++ b/scripts/measure-durability-swallow-family.mjs @@ -33,7 +33,7 @@ // THE CENSUS is NOT a gate: it exits 0 on any membership count, it prints "a // MEASUREMENT, not a gate" on every run of it, and the file is deliberately not // named `check:*` or `gen:*` so the #4203 script ledger has nothing to classify -// (the shape `scripts/measure-partial-retirement-annotation.mjs`, held at 7111e14ac9, established). The only +// (the shape `measure-partial-retirement-annotation.mjs` established). The only // non-zero exits are a `--self-test` failing its declared controls, an unknown // `--self-test=` mode (REFUSED, exit 2), and `ts-parse`'s EXIT_UNPARSEABLE. // diff --git a/scripts/measure-partial-retirement-annotation.mjs b/scripts/measure-partial-retirement-annotation.mjs new file mode 100644 index 00000000000..6e09ef10d4d --- /dev/null +++ b/scripts/measure-partial-retirement-annotation.mjs @@ -0,0 +1,534 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// measure-partial-retirement-annotation -- a ONE-SHOT MEASUREMENT INSTRUMENT for +// the #6635 signal. It is NOT a gate, it is NOT wired into any workflow, and it +// is deliberately not named `check:*` or `gen:*` so the #4203 script ledger has +// nothing to classify. +// +// node scripts/measure-partial-retirement-annotation.mjs # summary +// node scripts/measure-partial-retirement-annotation.mjs --hits # every hit +// node scripts/measure-partial-retirement-annotation.mjs --json # machine +// node scripts/measure-partial-retirement-annotation.mjs --tier A # one tier +// node scripts/measure-partial-retirement-annotation.mjs --file

# one file +// +// ## Why it exists +// +// #6635 proposed a gate: flag a file where a retired symbol's retirement issue +// number appears in SOME but not ALL of that symbol's mentions -- evidence that +// a retirement pass touched the file and provably missed siblings. The +// maintainer ruled on 2026-08-09 that the dispatchable deliverable is the +// MEASUREMENT, not the gate: total hits, a spot-verified true/false-positive +// split, and the exemption count a warning-tier rule would need on day one. +// This script produces those numbers reproducibly. Whether the gate is ever +// built is a separate decision the numbers feed. +// +// ## The signal, precisely +// +// For each (file F, retired symbol S): +// - collect every MENTION of S in F's prose (comments in code files, all text +// in markdown -- see `extractProse`); +// - a mention is ANNOTATED when its stanza cites one of S's retirement issue +// numbers, or frames S historically ("was removed", "until #N", "no longer", +// ...). The card is explicit that historical framing counts as annotated: +// the gate keys on ANNOTATION-presence, never on NAME-presence, which is +// what lets a tombstone error string -- whose whole job is to name the +// retired key -- pass; +// - flag F when at least one mention is annotated AND at least one other is +// not, i.e. someone updated this file and missed a sibling. +// +// A STANZA is the annotation window: a maximal run of consecutive lines with +// non-empty content after comment markers are stripped. In a TSDoc block that +// is the paragraph (` *` alone separates); in markdown it is the paragraph. +// `--window N` swaps it for a plus/minus-N-line window, for sensitivity. +// +// ## The three tiers, and why the split is load-bearing +// +// The repo's retirement playbook records that bare-name matching hits surviving +// families. The inventory is therefore tiered by how distinctive the name is, +// and the tiers are measured separately rather than pooled: +// +// A retired DEF names `ETLPipeline`, `WidgetManifest` distinctive +// B retired KEYS, qualified `Manifest.loading`, `X:key` distinctive +// C retired KEYS, bare `transform`, `tools`, `layout` the noise floor +// +// Tier C is measured to SIZE the noise, not because a gate should ship it. +// +// ## Inventory provenance +// +// The retired-symbol inventory is the repo's own declaration surface: +// +// 1. `RETIRED_DEFS_BY_MAJOR` (packages/spec/src/migrations/registry.ts) +// 2. `RETIRED_KEYS_BY_MAJOR` (same file) +// 3. `retiredKey()` tombstone guidance strings across packages/spec +// +// Issue attribution for (1) and (2) is read from the per-group comments inside +// each array literal, with the leading group (which has no per-group comment, +// only the block's doc comment) curated below in `CURATED_DEF_ISSUES`. Every +// attribution was hand-checked against the registry comments; the curation is +// spelled out rather than inferred so a reader can audit it. Attribution for +// (3) is mechanical: the guidance string states its own issue numbers. + +import { readFileSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join, relative } from 'node:path'; + +const ROOT = process.cwd(); +const REGISTRY = 'packages/spec/src/migrations/registry.ts'; + +// --------------------------------------------------------------------------- +// Inventory +// --------------------------------------------------------------------------- + +/** + * Retirement issue attribution for both registry tables, CURATED BY HAND from + * the per-group comments in `packages/spec/src/migrations/registry.ts`. + * + * ## Why this is hand-written rather than parsed -- a finding in its own right + * + * A gate would need "retired symbol -> its retirement issue" as DATA. The repo + * does not carry that mapping in machine-readable form: the registry tables are + * flat string arrays and the attribution lives only in free prose above each + * group. Parsing "nearest preceding `#NNNN`" was tried first and is wrong in + * two measured ways, both silent: + * + * - it reads the wrong number when a comment names more than one issue. The + * `transform` trio's header reads "The first entries since #4659 built this + * table (#5552)": #4659 built the TABLE, #5552 is the RETIREMENT. Nearest- + * preceding picks #4659 and every annotation check downstream then looks for + * a number no author ever wrote. + * - it cannot see repo boundaries. The #6946 group's comment cites + * "objectui#3829" and "objectui#3818" -- issue numbers in a DIFFERENT repo, + * indistinguishable from local ones once the prefix is dropped. + * + * So the attribution below is read off the comments by a human and stated + * explicitly, so a reader can audit it against the registry in one pass. + */ +const CURATED_ISSUES = { + // --- RETIRED_DEFS_BY_MAJOR[17] ------------------------------------------- + // "The first entry since #4725 built this table (#5552)" + 'shared/FieldMappingTransform': ['5552'], + // "The ten that follow are #5055 (ADR-0049 enforce-or-remove ...)" + 'ui/WidgetManifest': ['5055'], + 'ui/WidgetLifecycle': ['5055'], + 'ui/WidgetEvent': ['5055'], + 'ui/WidgetProperty': ['5055'], + 'ui/WidgetSource': ['5055'], + 'ui/I18nObject': ['5055'], + 'ui/PluralRule': ['5055'], + 'ui/NumberFormat': ['5055'], + 'ui/DateFormat': ['5055'], + 'ui/LocaleConfig': ['5055'], + // "// #5295 -- system/http-server.zod.ts runtime vocabulary" + 'system/ServerEvent': ['5295'], + 'system/ServerEventType': ['5295'], + 'system/ServerCapabilities': ['5295'], + 'system/ServerStatus': ['5295'], + // "// #6239 -- api/protocol.zod.ts view-management operations" + 'api/ListViewsRequest': ['6239'], + 'api/ListViewsResponse': ['6239'], + 'api/GetViewRequest': ['6239'], + 'api/GetViewResponse': ['6239'], + 'api/CreateViewRequest': ['6239'], + 'api/CreateViewResponse': ['6239'], + 'api/UpdateViewRequest': ['6239'], + 'api/UpdateViewResponse': ['6239'], + 'api/DeleteViewRequest': ['6239'], + 'api/DeleteViewResponse': ['6239'], + // "// #6414 -- automation/etl.zod.ts, the whole L2 layer" (the #6635 specimen) + 'automation/ETLPipeline': ['6414'], + 'automation/ETLPipelineRun': ['6414'], + 'automation/ETLSource': ['6414'], + 'automation/ETLDestination': ['6414'], + 'automation/ETLTransformation': ['6414'], + 'automation/ETLEndpointType': ['6414'], + 'automation/ETLTransformationType': ['6414'], + 'automation/ETLSyncMode': ['6414'], + 'automation/ETLRunStatus': ['6414'], + // "// #4914 -- the plugin manifest's `loading` block" + 'kernel/PluginLoadingConfig': ['4914'], + 'kernel/PluginLoadingStrategy': ['4914'], + 'kernel/PluginPreloadConfig': ['4914'], + 'kernel/PluginCodeSplitting': ['4914'], + 'kernel/PluginDynamicImport': ['4914'], + 'kernel/PluginInitialization': ['4914'], + 'kernel/PluginDependencyResolution': ['4914'], + 'kernel/PluginHotReload': ['4914'], + 'kernel/PluginCaching': ['4914'], + 'kernel/PluginSandboxing': ['4914'], + 'kernel/PluginPerformanceMonitoring': ['4914'], + + // --- RETIRED_KEYS_BY_MAJOR[17] ------------------------------------------- + // Same #5552 retirement as the def above -- one tombstone, three walked shapes. + 'data/ExternalFieldMapping:transform': ['5552'], + 'integration/ConnectorFieldMapping:transform': ['5552'], + 'shared/FieldMapping:transform': ['5552'], + // "// #5775 -- the SDUI component-props reconciliation" + 'ui/ElementRecordPickerProps:displayField': ['5775'], + 'ui/ElementRecordPickerProps:multiple': ['5775'], + 'ui/ElementRecordPickerProps:searchFields': ['5775'], + 'ui/PageCardProps:body': ['5775'], + // "// #6776 -- #5775's count was incomplete" + 'ui/PageTabsProps:type': ['6776'], + // "// #6748 -- ADR-0049 enforce-or-remove on the action-descriptor block" + 'automation/ActionDescriptor:isAsync': ['6748'], + // "// #6361 -- the notification-inbox pagination key" + 'api/ListNotificationsRequest:cursor': ['6361'], + 'api/ListNotificationsResponse:cursor': ['6361'], + // "// #4914 -- the plugin manifest's whole `loading` block" + 'kernel/Manifest:loading': ['4914'], + // "// #6815 -- the per-aggregation DISTINCT flag" + 'data/AggregationNode:distinct': ['6815'], + // "// #6946 -- three SDUI page-component props" (objectui#3829 / objectui#3818 + // are the sibling-repo halves, deliberately not used as the local annotation) + 'ui/PageCardProps:actions': ['6946'], + 'ui/PageHeaderProps:icon': ['6946'], + 'ui/RecordDetailsProps:layout': ['6946'], +}; + +/** Extract the body of `export const ...= {` up to the closing `};`. */ +function tableBody(source, name) { + const start = source.indexOf(`export const ${name}`); + if (start < 0) throw new Error(`table ${name} not found in ${REGISTRY}`); + const open = source.indexOf('{', start); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { + depth--; + if (depth === 0) return source.slice(open + 1, i); + } + } + throw new Error(`unterminated table ${name}`); +} + +/** + * Walk an array literal's lines, tracking the most recent `// #NNNN` comment + * line as the group's attribution, and attach it to each quoted entry. + */ +function entriesWithIssues(body) { + const out = []; + let current = null; + for (const raw of body.split('\n')) { + const line = raw.trim(); + if (line.startsWith('//')) { + const m = line.match(/#(\d{3,6})/); + if (m) current = [m[1]]; + continue; + } + const m = line.match(/^'([^']+)',?$/); + if (m) out.push({ entry: m[1], issues: current }); + } + return out; +} + +function buildInventory() { + const source = readFileSync(join(ROOT, REGISTRY), 'utf8'); + const symbols = []; + + // --- Tier A: retired def names ------------------------------------------- + for (const { entry, issues } of entriesWithIssues(tableBody(source, 'RETIRED_DEFS_BY_MAJOR'))) { + const attributed = CURATED_ISSUES[entry] ?? issues; + if (!attributed) continue; + const name = entry.split('/').pop(); + symbols.push({ + tier: 'A', + id: entry, + name, + issues: attributed, + // Word-boundary on a PascalCase def name. `\b` is safe here because every + // name in this table is a distinctive multi-word identifier. + pattern: new RegExp(`\\b${name}\\b`, 'g'), + }); + } + + // --- Tiers B and C: retired keys ----------------------------------------- + for (const { entry, issues: parsed } of entriesWithIssues(tableBody(source, 'RETIRED_KEYS_BY_MAJOR'))) { + const issues = CURATED_ISSUES[entry] ?? parsed; + if (!issues) continue; + const [defKey, key] = entry.split(':'); + const shape = defKey.split('/').pop(); + symbols.push({ + tier: 'B', + id: entry, + name: `${shape}.${key}`, + issues, + // Qualified only: `Shape.key` or `Shape:key`. + pattern: new RegExp(`\\b${shape}[.:]${key}\\b`, 'g'), + }); + symbols.push({ + tier: 'C', + id: `${entry} (bare)`, + name: key, + issues, + // The noise floor: a backticked bare key, the way prose spells it. + pattern: new RegExp('`' + key + '`', 'g'), + }); + } + + // --- Tier A (second source): retiredKey() tombstone guidance -------------- + // "`` was removed in @objectstack/spec 17.0.0 (#4661, #4964) -- ..." + // Test files are excluded: they quote SPECIMEN guidance strings (a + // `retired-key-migrate-sentence.test.ts` fixture spells "`x.y` was removed in + // @objectstack/spec 17.0.0 (#0000)"), which are illustrations, not retirements. + const specFiles = gitFiles().filter( + (f) => f.startsWith('packages/spec/src/') && f.endsWith('.ts') && !f.endsWith('.test.ts'), + ); + const seenTombstones = new Set(); + for (const file of specFiles) { + const text = readFileSync(join(ROOT, file), 'utf8'); + const re = /`([A-Za-z][A-Za-z0-9_.]*)`\s+(?:was|were)\s+removed[^\n]{0,200}/g; + let m; + while ((m = re.exec(text))) { + const key = m[1]; + const issues = [...m[0].matchAll(/#(\d{3,6})/g)].map((x) => x[1]); + if (!issues.length) continue; + // Only the qualified spellings; a bare tombstoned key is Tier C material + // and already represented by the registry table. + if (!key.includes('.')) continue; + const id = `tombstone:${key}`; + if (seenTombstones.has(id)) continue; + seenTombstones.add(id); + symbols.push({ + tier: 'B', + id, + name: key, + issues, + pattern: new RegExp(`\\b${key.replace(/\./g, '[.:]')}\\b`, 'g'), + }); + } + } + + return symbols; +} + +// --------------------------------------------------------------------------- +// Corpus +// --------------------------------------------------------------------------- + +let _files = null; +function gitFiles() { + if (_files) return _files; + const out = execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, maxBuffer: 64 * 1024 * 1024 }); + _files = out.toString('utf8').split('\u0000').filter(Boolean); + return _files; +} + +const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs)$/; +const DOC_EXT = /\.(md|mdx)$/; + +function corpus() { + return gitFiles().filter((f) => { + if (f.includes('node_modules/')) return false; + if (f.includes('/dist/') || f.startsWith('dist/')) return false; + return CODE_EXT.test(f) || DOC_EXT.test(f); + }); +} + +/** + * Reduce a source file to its PROSE: comment text and string-literal text, with + * every other character blanked out so line and column numbers survive. A + * markdown file is already prose and is returned unchanged. + * + * Blanking rather than deleting is what keeps a hit's reported line number + * usable for hand-verification, which this measurement depends on. + */ +function extractProse(text, file) { + if (DOC_EXT.test(file)) return text; + const out = new Array(text.length).fill(' '); + let i = 0; + const n = text.length; + let state = 'code'; + let quote = ''; + while (i < n) { + const c = text[i]; + const c2 = text[i + 1]; + if (c === '\n') { + out[i] = '\n'; + if (state === 'line') state = 'code'; + i++; + continue; + } + if (state === 'code') { + if (c === '/' && c2 === '/') { state = 'line'; i += 2; continue; } + if (c === '/' && c2 === '*') { state = 'block'; i += 2; continue; } + if (c === '"' || c === "'" || c === '`') { state = 'string'; quote = c; i++; continue; } + i++; + continue; + } + if (state === 'line') { out[i] = c; i++; continue; } + if (state === 'block') { + if (c === '*' && c2 === '/') { state = 'code'; i += 2; continue; } + out[i] = c; + i++; + continue; + } + // string + if (c === '\\') { i += 2; continue; } + if (c === quote) { state = 'code'; i++; continue; } + out[i] = c; + i++; + } + return out.join(''); +} + +// --------------------------------------------------------------------------- +// Stanzas +// --------------------------------------------------------------------------- + +/** Strip comment furniture so "is this line empty" is a question about content. */ +function stanzaContent(line) { + return line + .replace(/^\s*\*\/?/, '') + .replace(/^\s*\/\//, '') + .replace(/^\s*#{1,6}\s/, '') + .trim(); +} + +/** + * Partition a file's lines into stanzas: maximal runs of lines whose content is + * non-empty. Returns an array of { start, end } (1-based, inclusive). + */ +function stanzas(lines) { + const out = []; + let start = null; + for (let i = 0; i < lines.length; i++) { + const has = stanzaContent(lines[i]).length > 0; + if (has && start === null) start = i; + if (!has && start !== null) { out.push({ start, end: i - 1 }); start = null; } + } + if (start !== null) out.push({ start, end: lines.length - 1 }); + return out; +} + +function stanzaFor(index, list, lines, window) { + if (window > 0) { + return { start: Math.max(0, index - window), end: Math.min(lines.length - 1, index + window) }; + } + for (const s of list) if (index >= s.start && index <= s.end) return s; + return { start: index, end: index }; +} + +/** + * Historical framing -- the card's explicit carve-out. A mention that FRAMES the + * symbol as gone is already updated prose, whether or not it cites the number. + */ +const HISTORICAL = new RegExp( + [ + '\\b(?:was|were|is|are|has been|have been)\\s+(?:since\\s+)?(?:removed|retired|deleted|dropped|renamed|replaced|unpublished)\\b', + '\\bno longer\\b', + '\\buntil\\s+#\\d', + '\\bbefore\\s+#\\d', + '\\bformerly\\b', + '\\bused to\\b', + '\\bretired (?:at|in|by|the|it|this|that|under|whole)\\b', + '\\bremoved (?:at|in|by|the|it|this|that|under)\\b', + '\\bpre-\\d', + '\\bwent with\\b', + '\\bpermanently\\b', + '\\bdoes not exist\\b', + '\\bnever (?:existed|shipped)\\b', + ].join('|'), + 'i', +); + +// --------------------------------------------------------------------------- +// Scan +// --------------------------------------------------------------------------- + +function scan({ tiers, window, only }) { + const inventory = buildInventory().filter((s) => tiers.includes(s.tier)); + const files = only ? [only] : corpus(); + const hits = []; + + for (const file of files) { + const abs = join(ROOT, file); + if (!existsSync(abs)) continue; + let raw; + try { raw = readFileSync(abs, 'utf8'); } catch { continue; } + const prose = extractProse(raw, file); + const lines = prose.split('\n'); + const rawLines = raw.split('\n'); + const stanzaList = stanzas(lines); + + for (const sym of inventory) { + sym.pattern.lastIndex = 0; + if (!sym.pattern.test(prose)) continue; + sym.pattern.lastIndex = 0; + + const mentions = []; + for (let i = 0; i < lines.length; i++) { + sym.pattern.lastIndex = 0; + if (!sym.pattern.test(lines[i])) continue; + const s = stanzaFor(i, stanzaList, lines, window); + const text = lines.slice(s.start, s.end + 1).join('\n'); + const cited = sym.issues.some((n) => new RegExp(`#${n}\\b`).test(text)); + const historical = HISTORICAL.test(text); + mentions.push({ line: i + 1, cited, historical, text: rawLines[i].trim() }); + } + if (mentions.length < 2) continue; + + const annotated = mentions.filter((m) => m.cited); + const bare = mentions.filter((m) => !m.cited && !m.historical); + if (annotated.length === 0 || bare.length === 0) continue; + + hits.push({ + file, + tier: sym.tier, + symbol: sym.name, + id: sym.id, + issues: sym.issues, + mentions: mentions.length, + annotated: annotated.length, + historicalOnly: mentions.filter((m) => !m.cited && m.historical).length, + bare: bare.map((m) => ({ line: m.line, text: m.text })), + citedLines: annotated.map((m) => m.line), + }); + } + } + return { inventory, hits, fileCount: files.length }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const argv = process.argv.slice(2); +const arg = (flag, fallback) => { + const i = argv.indexOf(flag); + return i >= 0 ? argv[i + 1] : fallback; +}; + +const tiers = (arg('--tier', 'A,B,C') || '').split(',').map((t) => t.trim()).filter(Boolean); +const window = Number(arg('--window', '0')); +const only = arg('--file', null); +const result = scan({ tiers, window, only: only ? relative(ROOT, only) || only : null }); + +if (argv.includes('--inventory')) { + for (const s of result.inventory) console.log(`${s.tier} ${s.name} <- #${s.issues.join(' #')} (${s.id})`); + console.log(`\ninventory: ${result.inventory.length} symbols`); + process.exit(0); +} + +if (argv.includes('--json')) { + console.log(JSON.stringify(result.hits, null, 2)); + process.exit(0); +} + +const byTier = {}; +for (const h of result.hits) byTier[h.tier] = (byTier[h.tier] ?? 0) + 1; +const filesFlagged = new Set(result.hits.map((h) => h.file)).size; + +if (argv.includes('--hits')) { + for (const h of result.hits) { + console.log(`\n[${h.tier}] ${h.file} ${h.symbol} (retired by #${h.issues.join(', #')})`); + console.log(` mentions=${h.mentions} cited=${h.annotated} historical=${h.historicalOnly} bare=${h.bare.length}`); + console.log(` cited at lines: ${h.citedLines.join(', ')}`); + for (const b of h.bare) console.log(` BARE L${b.line}: ${b.text.slice(0, 150)}`); + } +} + +console.log(`\ncorpus: ${result.fileCount} files, inventory: ${result.inventory.length} symbols, window: ${window || 'stanza'}`); +console.log(`hits (file x symbol): ${result.hits.length}`); +console.log(`files flagged: ${filesFlagged}`); +for (const t of Object.keys(byTier).sort()) console.log(` tier ${t}: ${byTier[t]}`); From 9bc8449c0623ed14e002037dc39b98bfb1adbac1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:31:55 +0000 Subject: [PATCH 4/4] chore(scripts): hold back four large deletions for a second PR, keeping this one under the human-merge threshold measure-markdown-ts-blocks.mjs, measure-reserved-identity-name-census.mjs, pm/measurement-claim-triage.mjs (with its dispatch-gates.mjs docblock mention) and bench/runtime-publish-gate.bench.mts return to their state at 7111e14ac9. They are deleted in a follow-up PR once this one lands. Claude-Session: https://claude.ai/code/session_01LbdJFR3AmcKLZKQafYE1tN Co-authored-by: Claude --- scripts/bench/runtime-publish-gate.bench.mts | 612 ++++++++ scripts/measure-markdown-ts-blocks.mjs | 1276 +++++++++++++++++ .../measure-reserved-identity-name-census.mjs | 400 ++++++ scripts/pm/dispatch-gates.mjs | 3 +- scripts/pm/measurement-claim-triage.mjs | 653 +++++++++ 5 files changed, 2942 insertions(+), 2 deletions(-) create mode 100644 scripts/bench/runtime-publish-gate.bench.mts create mode 100644 scripts/measure-markdown-ts-blocks.mjs create mode 100644 scripts/measure-reserved-identity-name-census.mjs create mode 100644 scripts/pm/measurement-claim-triage.mjs diff --git a/scripts/bench/runtime-publish-gate.bench.mts b/scripts/bench/runtime-publish-gate.bench.mts new file mode 100644 index 00000000000..85cbdc2e365 --- /dev/null +++ b/scripts/bench/runtime-publish-gate.bench.mts @@ -0,0 +1,612 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +/** + * Runtime publish-gate benchmark (#9851) — what one `active` metadata publish + * costs at the shipped gate, as a function of the tenant's stored objects. + * + * ── The three modes ───────────────────────────────────────────────────────── + * + * --mode total (default) the whole-gate cost table. #9851's reading. + * --mode per-rule (#9905) the same total, ATTRIBUTED across the rules the + * door dispatches. + * --mode closure (#9905) the whole-gate cost when the `objects` collection + * is narrowed to the written item's reference closure. + * --mode package (#9612) the whole-gate cost when `objects` is narrowed to + * the written item's PACKAGE closure, by the SHIPPED + * deriver — plus the differential-verdict check and two + * positive controls. Unlike `--mode closure` this is not a + * hypothetical: it calls the same + * `narrowObjectsToPackageClosure` the gate calls, on a + * PACKAGED seed, so the number describes shipped behaviour. + * + * ⛔ `--mode closure` measures a HYPOTHETICAL. It narrows what the rules are + * handed HERE, in this script, and changes nothing about the shipped gate — + * which always receives the whole collection. Its closure deriver is a + * deliberately generous over-approximation written to bound the saving, NOT a + * proposed implementation: how (or whether) the shipped gate should scope its + * input is an open maintainer decision (#9612 / #9613), and one that this + * measurement exists to inform rather than to pre-empt. + * + * ── Why this file exists ──────────────────────────────────────────────────── + * + * Two published measurements of THIS operation disagreed by 16-25x with a + * non-constant ratio, and neither could be re-run: both came from throwaway + * harnesses that no longer exist. #9851 reconciled them — the disagreement is + * entirely the SHAPE OF THE SEEDED `objects` COLLECTION, not the span, not the + * warmup, and not a code change between the two refs — and left this file so + * the next reading is reproducible instead of re-litigated. + * + * npx tsx scripts/bench/runtime-publish-gate.bench.mts + * npx tsx scripts/bench/runtime-publish-gate.bench.mts --type object + * npx tsx scripts/bench/runtime-publish-gate.bench.mts --objects 21,105,420 --iterations 50 + * + * Requires `pnpm --filter '@objectstack/lint^...' build && pnpm --filter + * @objectstack/lint build` first: the gate is imported straight from + * `packages/lint/dist/runtime.js` — the built artifact the `./runtime` export + * points at, reached by path because this script is outside the workspace + * graph — so an unbuilt or stale `dist` reports the cost of the OLD registry + * (this is the `ablation-dist-preflight` hazard; the run prints the rules it + * actually dispatched, so a stale build shows up as a changed rule list rather + * than as a silently wrong number). + * + * ⚠️ Not a CI gate. Wall-clock numbers are machine-dependent and this asserts + * no threshold; it prints, and the operator compares. + * + * ── THE TIMED SPAN — inside vs outside ────────────────────────────────────── + * + * INSIDE the timed region: exactly one call to `runRuntimeAuthoringRules()` — + * the whole shipped gate for one write. That is `buildRuntimeWriteSnapshots` + * (baseline + candidate) plus the differential TWO passes of every rule that + * declares the written type in `runtimeTypes`, plus the fingerprint set-diff. + * + * OUTSIDE: seeding and cloning the stack, module load, and everything + * `saveMetaItem` wraps around the gate — the per-type Zod `safeParse`, + * persistence, hooks, audit, the HTTP envelope. + * + * ⭐ That exclusion is measured, not assumed to be small: the gate call alone + * reproduces the whole-`saveMetaItem` figure published as #9851's Reading A to + * within ~8%, so the gate IS substantially the whole per-publish bill and the + * span is NOT what separated the two published readings. + * + * ── WARMUP POLICY ─────────────────────────────────────────────────────────── + * + * `--warmup` iterations (default 5) run and are discarded, then `--iterations` + * (default 30) are timed and the MEDIAN is reported, with min/max so a noisy + * machine is visible. ⭐ Measured on this workload, warm and cold agree to + * within noise (a cold 3-iteration median lands inside the warm min/max band at + * both sizes), because a single iteration is already tens of milliseconds of + * the same loop — long enough for the JIT to settle mid-iteration. So warmup is + * kept for hygiene, NOT because it moves the number: it is not the explanation + * for any two harnesses disagreeing here. + * + * ── THE SEEDED STACK — the parameter that actually drives the cost ─────────── + * + * The gate's context is the bounded 4-collection `RuntimeStackContext` + * (`objects` / `permissions` / `books` / `datasets`); only `objects` is varied + * here, because that is the collection both published readings varied and the + * one a tenant grows. Two shapes ship, and running BOTH is the point: + * + * real — the shipped `examples/app-showcase` object declarations (22 of + * them), cloned under suffixed names up to N. Real authored surface: + * formula fields, validation rules, predicates, lookups — i.e. the + * expression-bearing keys `validateStackExpressions` walks. + * stub — synthetic 5-field objects carrying no expressions at all. + * + * ⭐ Same gate, same N, same warmup: `real` costs ~25x `stub`. **Per-publish + * cost is a function of the tenant's authored object SURFACE, not of its object + * COUNT** — count is only a proxy, and the proxy's constant is what the two + * published readings silently disagreed about. A number quoted as "X ms at N + * objects" without its stack shape is therefore not reproducible, and any + * acceptance threshold derived from one must name the shape it was set against. + * + * ⚠️ `real` imports the showcase app LIVE, so its absolute numbers drift as + * that app is edited. Deliberate — it is the only real, spec-parsed corpus in + * the repo, and a frozen copy would silently stop describing shipped metadata. + * Quote the ref you measured at. + * + * ── THE OBJECT DOOR, BOTH SIDES OF #4716 — MEASURED ON BOTH SHAPES (#9859) ── + * + * #9825's cost table for the object-door widening was published from the `stub` + * shape alone, so its `+0.31 / +3.25 ms` delta describes a stack in which the + * rules that judge validation rules and autonumber formats find nothing to + * judge. Both legs re-measured here at `17854cba0`, one machine, one session, + * median of 30 with warmup 5. The BEFORE leg is built from the PRE-WIDENING + * registry — `git restore --source=1408ae337^ -- packages/lint/src/authoring- + * rules.ts`, rebuild `@objectstack/lint`, then read `rules dispatched (2)` off + * the run's own header before believing any number (`ablation-dist-preflight` + * both ways: marker `P2 (#4463): judges an object/field declaration` PRESENT in + * `dist` on the BEFORE leg, ABSENT again after restoring): + * + * N=420, object write BEFORE (2 rules) AFTER (7 rules) delta + * stub 3.6-4.4 ms 8.5-8.6 ms ~ +4.6 ms + * real (showcase decls) 25.5-29.2 ms 282.8-304.4 ms ~ +265 ms + * + * N=21, object write BEFORE (2 rules) AFTER (7 rules) delta + * stub 0.19-0.21 ms 0.41-0.45 ms ~ +0.23 ms + * real (showcase decls) 1.20-1.32 ms 16.7-18.9 ms ~ +16.5 ms + * + * ⛔ The real-shape delta is NOT the stub delta scaled by the totals ratio, and + * this is the measurement that shows it: at N=420 the totals ratio is ~34x, so + * `+3.25 x 34` would predict ~+110 ms where the measured delta is ~+265 ms. A + * ratio measured on TOTALS does not transfer to a DELTA between two rule sets, + * because the sets walk different keys. `--mode per-rule` says exactly why — + * on the real shape at N=420 `validateRuleCompilability` alone is ~233 ms (82% + * of the whole gate) while on the stub shape it is ~0.04 ms (0.5%), the stub + * carrying no validation rules for it to judge. The two rules that were already + * at the door cost the same on both legs (~23-25 ms, real, N=420), as they must. + */ + +import { + runRuntimeAuthoringRules, + runtimeAuthoringRulesFor, + buildRuntimeWriteSnapshots, + narrowObjectsToPackageClosure, +} from '../../packages/lint/dist/runtime.js'; +import * as showcaseObjects from '../../examples/app-showcase/src/data/objects/index.js'; +import { allFlows } from '../../examples/app-showcase/src/automation/flows/index.js'; + +// Ambient `process` / `console`, declared to exactly the members used below. +// `scripts/**` is inside the ROOT tsconfig's program, and that config carries +// `lib: ['ES2020']` with no `types` — so Node's globals are absent and every +// use of them is a root-ledger tsc error (measured: 19 from this file alone, +// which is how it first landed). tsx provides the real objects at run time. +// Same shape and same reason as the ambient `process` in +// `examples/app-showcase/objectstack.config.ts`: narrow the declaration to +// what is actually called rather than widening the root's type surface, so +// this file pays its own way through `pnpm check:type-check-debt` instead of +// pushing a shrink-only ratchet back up. +declare const process: { + argv: string[]; + hrtime: { bigint(): bigint }; +}; +declare const console: { log(...args: unknown[]): void }; + +type AnyRec = Record; + +function arg(name: string, fallback: string): string { + const hit = process.argv.find((a) => a.startsWith(`--${name}=`)); + if (hit) return hit.slice(name.length + 3); + const i = process.argv.indexOf(`--${name}`); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback; +} + +const WRITE_TYPE = arg('type', 'flow'); +const SIZES = arg('objects', '21,420').split(',').map((s) => Number(s.trim())).filter(Boolean); +const ITERATIONS = Number(arg('iterations', '30')); +const WARMUP = Number(arg('warmup', '5')); +const MODE = arg('mode', 'total'); + +/** The shipped showcase object declarations — the `real` seed's source corpus. */ +const SHOWCASE_OBJECTS = (Object.values(showcaseObjects) as AnyRec[]) + .filter((o) => o && typeof o.name === 'string'); + +/** A real shipped flow, used as the written item for a `flow` publish. */ +const SHOWCASE_FLOW = (allFlows as AnyRec[]).find((f) => f.name === 'showcase_task_completed') + ?? (allFlows as AnyRec[])[0]; + +/** A minimal clean object body, used as the written item for an `object` publish. */ +const WRITTEN_OBJECT: AnyRec = { + name: 'bench_written_object', + label: 'Bench Written Object', + sharingModel: 'ReadWrite', + fields: { name: { type: 'text', label: 'Name' } }, +}; + +/** + * N objects cloned from the shipped showcase corpus, names suffixed to stay unique. + * + * ⚠️ The clone is `JSON.parse(JSON.stringify(...))`, so any FUNCTION-valued key + * in a showcase declaration is dropped rather than copied. Harmless for what is + * measured here — the runtime gate receives `sys_metadata` bodies, which are + * JSON at rest, so a JSON round-trip is the shape the real door sees — but it + * does mean this seed is a lower bound on a declaration whose authored surface + * lives partly in functions. Stated because the file's honesty about its own + * seed is the thing that makes a number taken from it re-runnable. + */ +function seedReal(n: number): AnyRec[] { + const out: AnyRec[] = []; + for (let i = 0; out.length < n; i++) { + const src = SHOWCASE_OBJECTS[i % SHOWCASE_OBJECTS.length]!; + const clone = JSON.parse(JSON.stringify(src)) as AnyRec; + const generation = Math.floor(i / SHOWCASE_OBJECTS.length); + if (generation > 0) clone.name = `${src.name}_c${generation}`; + out.push(clone); + } + return out; +} + +/** N synthetic 5-field objects carrying no expressions. */ +function seedStub(n: number): AnyRec[] { + return Array.from({ length: n }, (_, i) => ({ + name: `bench_object_${i}`, + label: `Bench Object ${i}`, + fields: { + name: { type: 'text', label: 'Name' }, + amount: { type: 'number', label: 'Amount' }, + active: { type: 'boolean', label: 'Active' }, + due: { type: 'date', label: 'Due' }, + notes: { type: 'textarea', label: 'Notes' }, + }, + })); +} + +const SHAPES: Record AnyRec[]> = { real: seedReal, stub: seedStub }; + +const ms = () => Number(process.hrtime.bigint()) / 1e6; +const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]!; + +function run(objects: AnyRec[], item: AnyRec) { + return runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects } }); +} + +function measure(objects: AnyRec[], item: AnyRec) { + for (let i = 0; i < WARMUP; i++) run(objects, item); + const samples: number[] = []; + for (let i = 0; i < ITERATIONS; i++) { + const t0 = ms(); + run(objects, item); + samples.push(ms() - t0); + } + return { median: median(samples), min: Math.min(...samples), max: Math.max(...samples) }; +} + +const item = WRITE_TYPE === 'flow' ? SHOWCASE_FLOW : WRITTEN_OBJECT; + +// The rules actually dispatched, printed BEFORE any timing: a stale `dist/`, or +// a rule ablated off the runtime surface, changes this line — so a number that +// moved for a registry reason can never be read as a number that moved for a +// performance reason. +const dispatched = run(SHAPES.real!(1), item).rulesRun; +console.log(`runtime publish gate — write type '${WRITE_TYPE}'`); +console.log(` rules dispatched (${dispatched.length}): ${dispatched.join(', ') || ''}`); +console.log(` timed span: one runRuntimeAuthoringRules() call (differential two-pass)`); +console.log(` warmup ${WARMUP} discarded, ${ITERATIONS} timed, median reported`); +if (dispatched.length === 0) { + console.log(` ⚠️ no rule gates '${WRITE_TYPE}' at the runtime surface — every number below is gate overhead only`); +} + +/** Median wall-clock of `fn`, same warmup/iteration policy as {@link measure}. */ +function timeMedian(fn: () => void): number { + for (let i = 0; i < WARMUP; i++) fn(); + const samples: number[] = []; + for (let i = 0; i < ITERATIONS; i++) { + const t0 = ms(); + fn(); + samples.push(ms() - t0); + } + return median(samples); +} + +/** #9851's reading: the whole-gate cost table, both shapes, every N. */ +function totalMode() { + console.log(''); + console.log(' shape N objects median min max'); + const table: Record> = {}; + for (const shape of Object.keys(SHAPES)) { + table[shape] = {}; + for (const n of SIZES) { + const r = measure(SHAPES[shape]!(n), item); + table[shape]![n] = r.median; + console.log( + ` ${shape.padEnd(6)} ${String(n).padStart(9)} ${r.median.toFixed(2).padStart(7)} ms ` + + `${r.min.toFixed(2).padStart(7)} ${r.max.toFixed(2).padStart(7)}`, + ); + } + } + + console.log(''); + for (const n of SIZES) { + const real = table.real![n]!; + const stub = table.stub![n]!; + console.log(` N=${n}: real / stub = ${(real / stub).toFixed(1)}x — same gate, same N, different authored surface`); + } + console.log(''); + console.log(' ⇒ per-publish cost tracks authored object SURFACE, not object COUNT.'); + console.log(' Quote the stack shape with any number taken from this gate (#9851).'); +} + +/** + * #9905 deliverable 2 — the same total, attributed across the dispatched rules. + * + * Each rule is timed over BOTH gate passes (baseline + candidate), because that + * is what one publish actually costs it, and the snapshots come from the gate's + * own exported {@link buildRuntimeWriteSnapshots} rather than a mirror — so the + * shares are attributed against the real construction. + * + * ⭐ The UNATTRIBUTED line is the anti-vacuity check and is printed even when it + * is uninteresting: per-rule shares that do not add up to the measured total + * mean the attribution is wrong, and that is the finding. Confirm the top share + * independently by ablating that rule off the door (`runtimeTypes`), rebuilding, + * and checking the total moves by roughly its share — `rules dispatched` above + * changes with it, so a stale `dist/` cannot masquerade as a saving. + */ +function perRuleMode() { + const rules = runtimeAuthoringRulesFor(WRITE_TYPE); + const ctx = { sduiManifest: undefined }; + for (const shape of Object.keys(SHAPES)) { + for (const n of SIZES) { + const objects = SHAPES[shape]!(n); + const snapshots = buildRuntimeWriteSnapshots({ type: WRITE_TYPE, item, context: { objects } }); + if (!snapshots) continue; + const total = timeMedian(() => { run(objects, item); }); + const per = rules + .map((r) => ({ + name: r.name, + ms: timeMedian(() => { + try { r.run(snapshots.baseline, ctx); } catch { /* the gate reports throws as findings */ } + try { r.run(snapshots.candidate, ctx); } catch { /* idem */ } + }), + })) + .sort((a, b) => b.ms - a.ms); + const sum = per.reduce((acc, p) => acc + p.ms, 0); + const pct = (x: number) => `${((x / total) * 100).toFixed(1).padStart(5)}%`; + console.log(''); + console.log(` shape=${shape} N=${n} — whole-gate total ${total.toFixed(2)} ms`); + for (const p of per) console.log(` ${p.name.padEnd(32)} ${p.ms.toFixed(2).padStart(8)} ms ${pct(p.ms)}`); + console.log(` ${'Σ attributed'.padEnd(32)} ${sum.toFixed(2).padStart(8)} ms ${pct(sum)}`); + console.log(` ${'unattributed (snapshot + diff)'.padEnd(32)} ${(total - sum).toFixed(2).padStart(8)} ms ${pct(total - sum)}`); + } + } +} + +/** Every string anywhere in a value — the closure seed scan, deliberately broad. */ +function allStrings(value: unknown, out: string[] = []): string[] { + if (typeof value === 'string') out.push(value); + else if (Array.isArray(value)) for (const v of value) allStrings(v, out); + else if (value && typeof value === 'object') for (const v of Object.values(value)) allStrings(v, out); + return out; +} + +/** + * The transitive reference closure of the written item over `objects`: every + * object NAMED anywhere in the item, expanded through the included objects' + * declared relational edges (`fields[].reference`) to a fixed point. + * + * Over-approximating on purpose — it seeds from any string that happens to equal + * an object name, so it can only ever be too LARGE. A saving measured against it + * is therefore a lower bound on what an exact closure would buy, which is the + * safe direction for a number that argues FOR narrowing. + */ +function referenceClosure(written: AnyRec, objects: AnyRec[]): AnyRec[] { + const byName = new Map(); + for (const o of objects) if (typeof o.name === 'string') byName.set(o.name, o); + const reached = new Set(allStrings(written).filter((s) => byName.has(s))); + const frontier = [...reached]; + while (frontier.length > 0) { + const owner = byName.get(frontier.pop()!)!; + for (const field of Object.values((owner.fields ?? {}) as AnyRec)) { + const ref = (field as AnyRec)?.reference; + for (const target of Array.isArray(ref) ? ref : [ref]) { + if (typeof target === 'string' && byName.has(target) && !reached.has(target)) { + reached.add(target); + frontier.push(target); + } + } + } + } + return [...reached].map((name) => byName.get(name)!); +} + +/** + * #9905 deliverable 1 — what NARROWING the objects collection would buy, timed. + * + * Prints `|closure| / N` (the size ratio, which is NOT the saving) next to the + * measured saving (which is), and re-runs the gate on the narrowed collection to + * confirm the differential VERDICT is unchanged — a saving that changes the + * verdict is not a saving, it is PR #7886's phantom-findings failure. + */ +function closureMode() { + const stable = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => + [...r.errors, ...r.advisories].map((f) => `${f.rule}|${f.where}|${f.path}|${f.message}`).sort().join('\n'); + for (const shape of Object.keys(SHAPES)) { + for (const n of SIZES) { + const objects = SHAPES[shape]!(n); + const closure = referenceClosure(item as AnyRec, objects); + const full = timeMedian(() => { run(objects, item); }); + const scoped = timeMedian(() => { run(closure, item); }); + const agree = stable(run(objects, item)) === stable(run(closure, item)); + console.log(''); + console.log(` shape=${shape} N=${n}: |closure| = ${closure.length} (closure/N = ${((closure.length / n) * 100).toFixed(1)}%)`); + console.log(` members: ${closure.map((o) => String(o.name)).join(', ') || ''}`); + console.log(` whole gate: full ${full.toFixed(2)} ms → closure ${scoped.toFixed(2)} ms — saving ${(((full - scoped) / full) * 100).toFixed(1)}%`); + console.log(` differential verdict unchanged: ${agree ? 'YES' : 'NO — narrowing changed the answer'}`); + } + } +} + + +// ───────────────────────────────────────────────────────────────────────────── +// #9612 — `--mode package`: what narrowing to the written item's PACKAGE +// closure buys, measured against the SHIPPED deriver rather than a hypothetical. +// ───────────────────────────────────────────────────────────────────────────── + +/** Objects per synthetic package in the packaged seed. */ +const PKG_SIZE = 22; +/** The package the written item belongs to. */ +const OWN_PKG = 'pkg_0'; +/** The one package `OWN_PKG` declares a dependency on. */ +const DEP_PKG = 'pkg_1'; +/** The platform's package — never declared as a dependency by anyone. */ +const PLATFORM_PKG = 'pkg_platform'; +/** A platform object the written item legitimately references. */ +const SYSTEM_OBJECT = 'sys_bench_ledger'; + +/** + * The seed, stamped with package provenance — the shape the ruling describes. + * + * A tenant that has grown to N objects is MANY packages, not one, so the seed + * distributes the corpus across `ceil(N / PKG_SIZE)` packages. It also carries + * ONE platform object (`isSystem`, `sys_` prefix, owned by a package nobody + * declares) so the "system objects are unconditionally in the closure" limb has + * something to be right or wrong about — control B below is what makes that a + * measurement rather than an assertion. + */ +function seedPackaged(shape: string, n: number): AnyRec[] { + const objects = SHAPES[shape]!(n).map((o, i) => ({ + ...o, + _packageId: `pkg_${Math.floor(i / PKG_SIZE)}`, + })); + const platform = JSON.parse(JSON.stringify(SHOWCASE_OBJECTS[0]!)) as AnyRec; + platform.name = SYSTEM_OBJECT; + platform.isSystem = true; + platform._packageId = PLATFORM_PKG; + return [...objects, platform]; +} + +/** `{ packageId, dependencies }` as the host resolves it from the registry. */ +const PACKAGE_SCOPE = { packageId: OWN_PKG, dependencies: [DEP_PKG] } as const; + +/** + * The written item, stamped into `OWN_PKG` and — for a flow — repointed at the + * platform object so control B has a reference that must survive the closure. + */ +function packagedWrittenItem(): AnyRec { + if (WRITE_TYPE !== 'flow') return { ...WRITTEN_OBJECT, _packageId: OWN_PKG }; + const flow = JSON.parse(JSON.stringify(SHOWCASE_FLOW)) as AnyRec; + const primary = referenceClosure(flow, SHAPES.real!(PKG_SIZE))[0]; + const primaryName = primary ? String(primary.name) : undefined; + const repointed = primaryName + ? (JSON.parse( + JSON.stringify(flow).split(JSON.stringify(primaryName).slice(1, -1)).join(SYSTEM_OBJECT), + ) as AnyRec) + : flow; + repointed._packageId = OWN_PKG; + return repointed; +} + +/** + * The item positive control A drives: one that really does reference objects in + * its OWN package, so dropping that package from the closure has something to + * break. For a flow that is the shipped showcase flow untouched; for an object + * write it is the same body the timing legs use, because the object door offers + * no cross-object reference to break — which is itself the measurement. + */ +function controlItemOwnPackage(): AnyRec { + if (WRITE_TYPE !== 'flow') return { ...WRITTEN_OBJECT, _packageId: OWN_PKG }; + const flow = JSON.parse(JSON.stringify(SHOWCASE_FLOW)) as AnyRec; + flow._packageId = OWN_PKG; + return flow; +} + +/** + * A finding's identity for verdict comparison — the semantic tuple, which + * since #10064 is the tuple AS EMITTED. + * + * The pre-#10064 reason for two readings is gone: the gate now keys a + * collection-resident finding's top-level collection entry by NAME + * (`objects.acme_invoice.sharingModel`), so narrowing the snapshot no longer + * moves anything in `path` and the raw tuple IS the semantic one. The + * differential comparison therefore reads the full + * `(rule, where, path, message)` tuple by default (the #10064 ruling). + * + * `verdictIndexInsensitive` survives as a CANARY, not as the default: the + * name-keying falls back to the positional spelling for an entry with no + * splice-safe name, and a positional top-level path CAN legitimately move + * under narrowing again. When the two readings disagree, the index-only + * difference is printed — hiding it would report a phantom as a verdict + * change (or vice versa) with nothing to diagnose it from. + */ +const stripIndices = (p: unknown) => String(p ?? '').replace(/\[\d+\]/g, '[i]'); +const verdict = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => + [...r.errors, ...r.advisories].map((f) => `${f.rule}|${f.where}|${f.path}|${f.message}`).sort(); +const verdictIndexInsensitive = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => + [...r.errors, ...r.advisories] + .map((f) => `${f.rule}|${f.where}|${stripIndices(f.path)}|${f.message}`) + .sort(); + +function diffCount(a: string[], b: string[]): { added: number; removed: number } { + const bs = new Set(b); + const as = new Set(a); + return { + added: b.filter((x) => !as.has(x)).length, + removed: a.filter((x) => !bs.has(x)).length, + }; +} + +function packageMode() { + const item = packagedWrittenItem(); + console.log(''); + console.log(` package closure — own '${OWN_PKG}' + declared dep '${DEP_PKG}' + platform/system + unpackaged`); + console.log(` seed: ${PKG_SIZE} objects per package, plus one '${SYSTEM_OBJECT}' owned by '${PLATFORM_PKG}' (declared by nobody)`); + + for (const shape of Object.keys(SHAPES)) { + for (const n of SIZES) { + const objects = seedPackaged(shape, n); + const closure = narrowObjectsToPackageClosure(objects, PACKAGE_SCOPE) as AnyRec[]; + + const runFull = () => runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects } }); + const runScoped = () => + runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects }, packageScope: PACKAGE_SCOPE }); + + const full = timeMedian(() => { runFull(); }); + const scoped = timeMedian(() => { runScoped(); }); + const fullV = runFull(); + const scopedV = runScoped(); + + console.log(''); + console.log( + ` shape=${shape} N=${objects.length}: |closure| = ${closure.length}` + + ` (closure/N = ${((closure.length / objects.length) * 100).toFixed(1)}%)`, + ); + console.log( + ` whole gate: full ${full.toFixed(2)} ms → package closure ${scoped.toFixed(2)} ms` + + ` — saving ${(((full - scoped) / full) * 100).toFixed(1)}%`, + ); + // [#10064] The semantic tuple is the default — and it is the tuple as + // emitted, path included, now that the wire path is name-keyed. The + // index-insensitive reading prints only when it disagrees (a positional + // fallback path moved), so an index-only artefact cannot silently pose + // as a verdict change. + const semD = diffCount(verdict(fullV), verdict(scopedV)); + const insD = diffCount(verdictIndexInsensitive(fullV), verdictIndexInsensitive(scopedV)); + console.log( + ` differential verdict: ${semD.added === 0 && semD.removed === 0 ? 'UNCHANGED' : `CHANGED (+${semD.added} / -${semD.removed})`}` + + (semD.added === insD.added && semD.removed === insD.removed + ? '' + : ` · index-insensitive reading disagrees: +${insD.added} / -${insD.removed} (a positional-fallback path moved)`), + ); + console.log(` findings: full ${fullV.errors.length}e/${fullV.advisories.length}a → scoped ${scopedV.errors.length}e/${scopedV.advisories.length}a`); + + // ── Positive control A — the closure loses the written package itself. + // + // Driven by {@link controlItemOwnPackage}, NOT by `item`: the timed item + // above was repointed at the platform object for control B's sake, so it + // no longer references its own package and would make this control + // vacuous BY CONSTRUCTION rather than by a property of the door. This + // control needs an item whose references live in `pkg_0`. + // + // If it prints a zero delta the verdict check above is not falsifiable on + // this door, so it is printed whatever it says. + const ctrlItem = controlItemOwnPackage(); + const ctrlAFull = runRuntimeAuthoringRules({ type: WRITE_TYPE, item: ctrlItem, context: { objects } }); + const ctrlA = objects.filter((o) => { + const owner = String(o._packageId ?? ''); + return owner === DEP_PKG || o.isSystem === true || String(o.name ?? '').startsWith('sys_'); + }); + const ctrlAV = runRuntimeAuthoringRules({ type: WRITE_TYPE, item: ctrlItem, context: { objects: ctrlA } }); + const ctrlAD = diffCount(verdict(ctrlAFull), verdict(ctrlAV)); + console.log( + ` positive control A (own package dropped, |set| = ${ctrlA.length}): ` + + `${ctrlAD.added === 0 && ctrlAD.removed === 0 ? '⚠️ NO DELTA — control is vacuous on this door' : `delta +${ctrlAD.added} / -${ctrlAD.removed}`}`, + ); + + // ── Positive control B — the card's named hazard: a closure that omits + // SYSTEM objects. The written item references `sys_bench_ledger`, which + // no package declares, so only the unconditional system limb keeps it. + const ctrlB = objects.filter((o) => { + const owner = String(o._packageId ?? ''); + return owner === OWN_PKG || owner === DEP_PKG; + }); + const ctrlBV = runRuntimeAuthoringRules({ type: WRITE_TYPE, item, context: { objects: ctrlB } }); + const ctrlBD = diffCount(verdict(fullV), verdict(ctrlBV)); + console.log( + ` positive control B (system objects dropped, |set| = ${ctrlB.length}): ` + + `${ctrlBD.added === 0 && ctrlBD.removed === 0 ? '⚠️ NO DELTA — this door has no objects×objects coupling' : `delta +${ctrlBD.added} / -${ctrlBD.removed}`}`, + ); + } + } +} + +if (MODE === 'per-rule') perRuleMode(); +else if (MODE === 'closure') closureMode(); +else if (MODE === 'package') packageMode(); +else totalMode(); diff --git a/scripts/measure-markdown-ts-blocks.mjs b/scripts/measure-markdown-ts-blocks.mjs new file mode 100644 index 00000000000..12ec6b3955b --- /dev/null +++ b/scripts/measure-markdown-ts-blocks.mjs @@ -0,0 +1,1276 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * measure-markdown-ts-blocks -- do the fenced TypeScript blocks in package-root + * Markdown compile? (#18715) + * + * node scripts/measure-markdown-ts-blocks.mjs # the census + * node scripts/measure-markdown-ts-blocks.mjs --json # machine-readable + * node scripts/measure-markdown-ts-blocks.mjs --only # one file (repeatable) + * node scripts/measure-markdown-ts-blocks.mjs --controls-only # just the two controls + * node scripts/measure-markdown-ts-blocks.mjs --self-test # the extractor's own battery + * + * ⛔ THIS IS A MEASUREMENT INSTRUMENT, NOT A GATE. It exits 0 on any census + * outcome, is wired into no CI job and no package `test`/`lint` chain, and + * nothing in this repository fails because of what it reports. Turning a + * census into a required gate is a NEW REQUIRED GATE — the maintainer's floor + * — and the wiring options belong in a letter to the maintainer, never in a + * `package.json` edit made by whoever wrote the instrument. + * + * It exits non-zero for exactly one reason: THE INSTRUMENT ITSELF COULD NOT + * RUN (its controls misbehaved, or the workspace is not built). "Could not + * run" is a failure, not a skip — a census that silently degrades to "0 + * failures" because nothing resolved is the worst possible output. + * + * ## POPULATION + * + * Markdown sitting at the ROOT of a package: every `*.md` directly inside a + * directory under `packages/` that has a `package.json`. Two strata are + * reported separately and ⛔ never summed into one headline: + * + * - HAND-WRITTEN — README.md, ARCHITECTURE.md, PHASE2_IMPLEMENTATION.md, … + * A failing block here is a defect an author can repair. + * - CHANGELOG.md — RELEASE-OWNED and compiled by `changeset version` from + * `.changeset/*.md` (AGENTS.md, Documentation Guardrails). Its blocks are + * HISTORY: before/after migration snippets that deliberately show removed + * APIs. A failing block here is frequently CORRECT documentation, and it + * cannot be repaired in a code PR at all. Counting the two together + * produces a number nobody can act on. + * + * `content/docs/**` is a DIFFERENT population with its own gates and is out of + * scope here (#18715 holds it out explicitly). + * + * ## THE ELISION CONVENTION — the one real design question, and its answer + * + * These documents are full of partial snippets. A census that counts every + * partial block as a failure measures nothing. So how does a block declare + * itself partial? + * + * ⛔ THE OBVIOUS ANSWER IS DISQUALIFIED, and this instrument proves it rather + * than asserting it. The obvious answer is an EXCLUSION rule: a block carrying + * an elision marker (`// ...`) is skipped. Run that rule against the one + * failure this repository has already PROVEN — `PHASE2_IMPLEMENTATION.md`'s + * pre-#18712 `kernel.logger` block, measured at `tsc --noEmit --strict` exit 2 + * with TS2341 — and it is excluded, because that block ends with the line + * `// ... plugin registration code ...`. An exclusion rule would have hidden + * the only defect in this family anyone has ever measured. That is the whole + * argument, and `FIRING_CONTROL` below is the standing proof of it. + * + * ✅ THE RULE THIS INSTRUMENT USES IS A TOLERANCE RULE, not an exclusion rule. + * Every block is compiled. What a legitimately partial block can PRODUCE is + * forgiven; nothing else is: + * + * forgiven TS2304 / TS2552 / TS2503 a name or namespace the block elided + * forgiven TS2307 on a RELATIVE specifier the reader's own file + * (`./my-kernel`, `./objectstack.config.js`) — never a + * `@objectstack/*` one, see below + * counted everything else, including TS2341 (private member), TS2339 + * (no such property), TS2345 (wrong argument type), TS2305 / + * TS2724 (no such export) and TS2307 on a `@objectstack/*` + * specifier + * + * TS2307 on a `@objectstack/*` specifier is deliberately NOT forgiven: the + * `paths` map this instrument compiles against is generated from each + * package's own `exports` map, so an unresolved one means the document tells a + * reader to import a subpath the package does not publish. That is the + * `check-published-readme-exports` family of defect, arriving here for free. + * + * ## BOTH NUMBERS, AND WHICH DIRECTION EACH ONE LIES IN + * + * The census reports RAW and TOLERANT side by side, and neither is "the" + * answer: + * + * RAW every block with at least one diagnostic. An UPPER BOUND: it + * counts fragments that were never meant to stand alone. + * TOLERANT every block with at least one diagnostic outside the forgiven + * set. A LOWER BOUND, and this is the direction that matters: a + * forgiven TS2304 leaves that name typed `any`, and every + * downstream use of it is then unchecked. Real defects hide behind + * an elision; they never appear because of one. + * + * ⇒ The honest sentence is "between TOLERANT and RAW", and the gate question + * is answered by TOLERANT, because that is the number a gate could be built to + * hold at zero. + * + * ## THE FORWARD CONVENTION, for the blocks tolerance cannot absolve + * + * A block that is a bare fragment — a type signature, half an object literal — + * produces SYNTAX errors, and ⛔ syntax is not forgiven: a tolerance rule that + * forgave TS1xxx would forgive a typo. Those blocks need an explicit + * declaration, and the proposed spelling is an info-string tag: + * + * ```ts partial + * + * ⛔ Zero blocks in the corpus carry it today, so it contributes nothing to + * this census — it is stated so the census can COUNT how many blocks would + * need one (`needs-explicit-partial-tag` below), which is the cost line the + * wiring letter needs. An `exclusion` census is also printed, labelled as the + * disqualified reading, so the number cannot hide inside the convention. + * + * ## THE TWO CONTROLS — run on EVERY census, not only under --self-test + * + * An instrument that finds zero failures and was never shown capable of + * finding one has measured nothing. + * + * GREEN_CONTROL a block that MUST compile. It fails when the workspace is + * not built (no `dist/*.d.ts` to resolve against), which is + * the state in which every other block would report TS2307 + * and the census would read as a catastrophe made of + * nothing. The run REFUSES instead of printing it. + * FIRING_CONTROL the reconstructed pre-#18712 `kernel.logger` block, which + * MUST be reported as failing, MUST carry TS2341, and MUST + * still fail under the tolerance rule. It is also the proof + * that the exclusion rule is disqualified: the same fixture + * is reported as EXCLUDED by that rule, every run. + * + * Both go through the identical path as corpus blocks — same extractor, same + * normalisation, same program — so a control green is a statement about the + * instrument that actually ran. + * + * ## NORMALISATION, stated because it changes the bytes compiled + * + * 1. The opening fence's indentation is stripped from every body line, so a + * block inside a list item compiles as the code the reader sees. + * 2. `export {};` is appended to every block. One tsc program holds every + * block at once; without it a block with no import/export is a SCRIPT in + * the global scope and collides with every other one (TS2451 on names + * like `kernel`), which would be a census of this instrument's own + * packaging. Appending at the end leaves every reported line number + * equal to the line number in the Markdown. + * + * ## WHY THE `tsc` BINARY, AND WHY TWO PASSES + * + * The TypeScript compiler API is deliberately not used. `scripts/ts-parse.mjs` + * is the one sanctioned door to it under `scripts/**`, and its contract is to + * EXIT on a source it cannot parse — correct for a gate walking real files, + * wrong here, where a block that does not parse is the measurement's subject. + * Driving the binary also makes this instrument ask #18715's own question + * verbatim: `tsc --noEmit --strict`. + * + * ⚠️ MEASURED PROPERTY OF THE CLI, which one pass gets wrong: when any file in + * a program has a SYNTACTIC diagnostic, tsc reports the syntax errors and + * never type-checks ANY file in that program. One pass over this corpus + * returned 772 TS1xxx and not one semantic diagnostic — the firing control + * included, which is exactly how this was caught. So pass 1 finds the blocks + * that do not parse and pass 2 re-runs over the ones that do. A block that + * does not parse carries its syntax diagnostics and no semantic ones, which is + * honest: tsc cannot read it either. + * + * This instrument was first written against `ts.createProgram` + + * `getPreEmitDiagnostics` and then rewritten onto the binary. Over all 1002 + * corpus blocks the two implementations agree on every block's verdict — raw, + * tolerant and well-formed-and-wrong — with ZERO disagreements. The binary run + * reports fewer DIAGNOSTICS on unparseable blocks, because the checker noise + * the API produced over wreckage is gone. + * + * ## COMPILER OPTIONS, and why each deviation from the repo's own tsconfig + * + * strict: true — #18715's proven measurement is `tsc --noEmit + * --strict`; kept verbatim so this instrument and + * that reading answer the same question. + * moduleResolution: — `exports` maps are honoured (so an unpublished + * Bundler subpath still fails) WITHOUT NodeNext's + * mandatory `.js` extension on relative + * specifiers, which would manufacture a failure + * out of a documentation style choice. + * noUnusedLocals: false — a snippet that declares a value to SHOW it is + * noUnusedParameters: not a defect. The repo's own tsconfig turns + * false these on for source; doing so here would count + * prose as breakage. + * lib: ES2022 + DOM — deliberately generous. Every ambient name the + * types: node instrument can supply is one fewer manufactured + * failure. + */ + +import { readFileSync, existsSync, readdirSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..'); +const PACKAGES_DIR = join(REPO_ROOT, 'packages'); +const nodeRequire = createRequire(import.meta.url); +// ⛔ The TypeScript compiler API is deliberately NOT imported here. This +// instrument's SUBJECT is blocks that may not parse, so `createProgramChecked` +// — the one sanctioned API door under `scripts/**` — is the wrong tool: it +// exits 3 on the first syntactic diagnostic, which for this census is DATA. +// Driving the real `tsc` binary answers exactly the question #18715 measured +// (`tsc --noEmit --strict`, exit 2, TS2341) and keeps the parser home's rule +// that nothing outside it reaches the parser API. +const TSC_BIN = join(dirname(nodeRequire.resolve('typescript/package.json')), 'bin', 'tsc'); + +// ── Population ───────────────────────────────────────────────────────────── + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.turbo', '.git', 'json-schema']); + +/** Every directory under `root` that carries a package.json, `root` included. */ +export function findPackageRoots(root, out = []) { + if (!existsSync(root)) return out; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + findPackageRoots(join(root, entry.name), out); + } + if (existsSync(join(root, 'package.json'))) out.push(root); + return out; +} + +export function populationFiles() { + const files = []; + for (const pkgRoot of findPackageRoots(PACKAGES_DIR)) { + for (const name of readdirSync(pkgRoot)) { + if (name.endsWith('.md')) files.push(join(pkgRoot, name)); + } + } + return files.sort(); +} + +/** CHANGELOG.md is release-owned history, reported as its own stratum. */ +export const stratumOf = (file) => (/(^|\/)CHANGELOG\.md$/.test(file) ? 'changelog' : 'handwritten'); + +/** The literal `packages/PKG/NAME.md` glob #18715 names, kept as a reportable sub-stratum. */ +export const isDepthOne = (relPath) => relPath.split('/').length === 3; + +// ── Extraction ───────────────────────────────────────────────────────────── + +const FENCE_RE = /^([ \t]*)(`{3,}|~{3,})[ \t]*(.*)$/; +const TS_LANGS = new Set(['ts', 'typescript', 'tsx']); + +/** + * Fenced blocks whose info-string's FIRST word is a TypeScript language. + * A fence opened inside an already-open block is body text, never a new block: + * a ```ts fence quoted inside a ```md example is not code this repo ships. + */ +export function extractBlocks(text) { + const lines = text.split('\n'); + const blocks = []; + let open = null; + for (let i = 0; i < lines.length; i += 1) { + const m = FENCE_RE.exec(lines[i]); + if (!m) { + if (open) open.body.push(lines[i]); + continue; + } + const [, indent, fence, info] = m; + if (!open) { + open = { indent, char: fence[0], len: fence.length, info: info.trim(), firstBodyLine: i + 2, body: [] }; + continue; + } + const closes = fence[0] === open.char && fence.length >= open.len && info.trim() === ''; + if (!closes) { + open.body.push(lines[i]); + continue; + } + const lang = open.info.split(/\s+/)[0].toLowerCase(); + if (TS_LANGS.has(lang)) { + const dedent = open.indent.length + ? open.body.map((l) => (l.startsWith(open.indent) ? l.slice(open.indent.length) : l.replace(/^[ \t]+/, ''))) + : open.body; + blocks.push({ + lang, + info: open.info, + firstBodyLine: open.firstBodyLine, + declaredPartial: /(^|\s)partial(\s|$)/.test(open.info), + code: dedent.join('\n'), + }); + } + open = null; + } + return blocks; +} + +/** + * The INFERRED elision marker: a comment carrying an ellipsis, or a bare `...` + * line. Used ONLY to compute the disqualified `exclusion` reading — the + * tolerance rule never consults it. + */ +export function hasElisionMarker(code) { + return code.split('\n').some((line) => { + const t = line.trim(); + if (t === '...') return true; + if (t.startsWith('//') && t.includes('...')) return true; + if (t.startsWith('/*') && t.includes('...')) return true; + if (t.startsWith('*') && t.includes('...')) return true; + return false; + }); +} + +/** One tsc program holds every block, so every block must be a module. */ +export const normalise = (code) => `${code}\nexport {};\n`; + +// ── Diagnostic classification ────────────────────────────────────────────── + +const FORGIVEN_NAME_CODES = new Set([2304, 2552, 2503]); +// "Cannot find name 'it' / 'describe' / 'process' / 'document'" — the READER's +// ambient environment (a test runner, node, the DOM), never something the +// document could have written. Same for a third-party module that ships no +// types, and for JSX with no `JSX.IntrinsicElements` in scope. +const FORGIVEN_AMBIENT_CODES = new Set([2580, 2581, 2582, 2583, 2584, 2591, 2592, 2593, 7016, 7026]); +const SYNTAX_MAX = 1999; + +/** + * Pure so the self-test can drive it without a compiler: takes the two fields + * of a diagnostic that decide its family. + */ +export function classifyDiagnostic({ code, message }) { + if (code >= 1000 && code <= SYNTAX_MAX) return { family: 'syntax', forgiven: false }; + if (FORGIVEN_NAME_CODES.has(code)) return { family: 'elided-name', forgiven: true }; + if (FORGIVEN_AMBIENT_CODES.has(code)) return { family: 'missing-ambient-environment', forgiven: true }; + if (code === 2307) { + const spec = /Cannot find module '([^']+)'/.exec(message)?.[1] ?? ''; + if (spec.startsWith('.')) return { family: 'doc-local-path', forgiven: true, specifier: spec }; + if (spec.startsWith('@objectstack/')) return { family: 'unpublished-subpath', forgiven: false, specifier: spec }; + return { family: 'external-not-installed', forgiven: true, specifier: spec }; + } + if (code >= 7000 && code <= 7099) return { family: 'implicit-any', forgiven: false }; + return { family: 'semantic', forgiven: false }; +} + +/** The families `classifyDiagnostic` marks forgiven — a legitimately partial block can produce these and nothing else. */ +export const FORGIVEN_FAMILIES = new Set(['elided-name', 'doc-local-path', 'external-not-installed', 'missing-ambient-environment']); + +/** + * `tsc --pretty false` prints one diagnostic per line, with wrapped detail + * indented under it. Parsed here rather than read from the API, and held + * honest by the two controls: if this parser broke, GREEN_CONTROL would read + * as failing or FIRING_CONTROL as clean, and the run would refuse. + */ +export function parseTscOutput(text) { + const HEAD = /^(\S[^(]*)\((\d+),(\d+)\): (?:error|warning) TS(\d+): (.*)$/; + const BARE = /^(?:error|warning) TS(\d+): (.*)$/; + const diagnostics = []; + const global = []; + for (const line of text.split('\n')) { + const head = HEAD.exec(line); + if (head) { + diagnostics.push({ file: head[1], line: Number(head[2]), code: Number(head[4]), message: head[5] }); + continue; + } + const bare = BARE.exec(line); + if (bare) { + global.push({ code: Number(bare[1]), message: bare[2], line: null }); + continue; + } + const tail = diagnostics.length > 0 ? diagnostics : global; + if (/^\s+\S/.test(line) && tail.length > 0) tail[tail.length - 1].message += ` ${line.trim()}`; + } + return { diagnostics, global }; +} + +// ── Controls ─────────────────────────────────────────────────────────────── + +/** MUST compile. Its failure means the workspace is not built — refuse, never report. */ +export const GREEN_CONTROL = [ + '# green control', + '', + '```ts', + "import { ObjectKernel } from '@objectstack/core';", + '', + 'const KernelCtor: typeof ObjectKernel = ObjectKernel;', + 'export { KernelCtor };', + '```', + '', +].join('\n'); + +/** + * MUST be reported failing, with TS2341, under BOTH readings. + * Reconstructed byte-for-byte from PR #18712's diff (the `-` side of + * packages/core/PHASE2_IMPLEMENTATION.md's "Integration with Kernel" block), + * the one failure in this family anyone has measured: `tsc --noEmit --strict` + * exit 2, TS2341 `Property 'logger' is private`. + * + * ⭐ Its last comment line is `// ... plugin registration code ...`, which is + * why the exclusion reading is reported as DISQUALIFIED rather than offered. + */ +export const FIRING_CONTROL = [ + '# firing control', + '', + '```typescript', + 'import { ', + ' ObjectKernel,', + ' PluginHealthMonitor,', + ' HotReloadManager,', + ' DependencyResolver,', + ' PluginPermissionManager,', + ' PluginSandboxRuntime', + "} from '@objectstack/core';", + '', + "const kernel = new ObjectKernel({ logger: { level: 'info' } });", + '', + '// Initialize Phase 2 components', + 'const healthMonitor = new PluginHealthMonitor(kernel.logger);', + 'const hotReload = new HotReloadManager(kernel.logger);', + 'const depResolver = new DependencyResolver(kernel.logger);', + 'const permManager = new PluginPermissionManager(kernel.logger);', + 'const sandbox = new PluginSandboxRuntime(kernel.logger);', + '', + '// Register plugins with enhanced features', + '// ... plugin registration code ...', + '', + '// Bootstrap kernel', + 'await kernel.bootstrap();', + '```', + '', +].join('\n'); + +/** + * The three specifiers UNPUBLISHED_SUBPATH_CONTROL exists to keep COUNTED. The + * block below and this list are held equal by a self-test battery, so editing + * one without the other is a failure rather than a silent narrowing. + */ +export const UNPUBLISHED_SUBPATH_SPECIFIERS = Object.freeze([ + '@objectstack/spec/hub', + '@objectstack/core/errors', + '@objectstack/core/plugin', +]); + +/** + * MUST be reported failing, with a TS2307 on EACH of the three specifiers + * above, every one of them classified `unpublished-subpath` and forgiven by + * nothing. + * + * Reconstructed line-for-line from `packages/spec/V3_MIGRATION_GUIDE.md` as it + * read BEFORE #18753 repaired it. That document announced a migration that + * never happened: `466f32ea22` deleted `createErrorResponse`, + * `getHttpStatusForCategory` and `definePlugin` from spec and created neither + * destination, so the guide told a reader to import from subpaths their + * packages publish in no `exports` entry. + * + * ⭐ SYNTHETIC ON PURPOSE, and it is the whole reason this fixture exists. A + * control that IS the live document disarms itself the moment the document is + * repaired — the census number falls, and the fourth instance in the next guide + * is just as green. FIRING_CONTROL was reconstructed from PR #18712's diff for + * exactly this reason; this is the same move for the `unpublished-subpath` + * family, which FIRING_CONTROL (a TS2341) does not cover at all. + * + * ⭐ WHAT IT GUARDS IS THE TOLERANCE RULE, not the corpus. Widen + * `classifyDiagnostic` until a `@objectstack/*` TS2307 is forgiven and every + * unresolvable documented import in this repository goes quiet at once — with + * no other control able to notice. That is why it sits on the refusal list + * beside the other two instead of firing only under `--self-test`. + * + * ⚠️ THE COST, stated because it is real: if one of the three subpaths is ever + * genuinely published, this control stops producing TS2307 for it and EVERY + * census refuses until someone edits the fixture. That is the same exposure + * FIRING_CONTROL already carries (make `ObjectKernel.logger` public and it + * stops producing TS2341), and it is answered the same way — the refusal text + * names the remedy instead of leaving a stale fixture to be diagnosed. + * + * ⛔ It carries NO elision marker and NO syntax error, both load-bearing: a + * marker would let the (disqualified) exclusion reading skip it, and a syntax + * error would land it in pass 1's unparseable set, where tsc never type-checks + * it and the three TS2307s it exists to produce would never be reported at all. + */ +export const UNPUBLISHED_SUBPATH_CONTROL = [ + '# unpublished-subpath control', + '', + '```typescript', + "import { TenantSchema } from '@objectstack/spec/hub';", + "import { createErrorResponse, getHttpStatusForCategory } from '@objectstack/core/errors';", + "import { definePlugin } from '@objectstack/core/plugin';", + '```', + '', +].join('\n'); + +// ── Resolution: a `paths` map generated from each package's own exports map ── + +/** Walk a conditional exports value for the `types` condition an importer sees. */ +export function pickTypesTarget(value) { + if (typeof value === 'string') return /\.d\.[cm]?ts$/.test(value) ? value : null; + if (!value || typeof value !== 'object') return null; + if (typeof value.types === 'string') return value.types; + for (const condition of ['import', 'node', 'default', 'require', 'browser']) { + if (condition in value) { + const found = pickTypesTarget(value[condition]); + if (found) return found; + } + } + return null; +} + +/** + * ⛔ A subpath with no entry here gets NO mapping, so it fails TS2307 and is + * counted — that is the point: the census sees exactly what a consumer sees. + */ +export function workspacePaths(packageRoots) { + const paths = {}; + for (const dir of packageRoots) { + let manifest; + try { + manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + } catch { + continue; + } + if (!manifest.name || manifest.private === true) continue; + const add = (specifier, target) => { + if (target) paths[specifier] = [join(dir, target)]; + }; + if (manifest.exports && typeof manifest.exports === 'object') { + for (const [key, value] of Object.entries(manifest.exports)) { + if (!key.startsWith('.')) continue; + const specifier = key === '.' ? manifest.name : `${manifest.name}/${key.slice(2)}`; + add(specifier, pickTypesTarget(value)); + } + } else if (typeof manifest.exports === 'string') { + add(manifest.name, pickTypesTarget(manifest.exports)); + } + if (!paths[manifest.name] && typeof manifest.types === 'string') add(manifest.name, manifest.types); + } + return paths; +} + +/** Third-party packages live in each package's own store under pnpm, never at the root. */ +export function externalPaths(specifiers, packageRoots) { + const paths = {}; + const searchRoots = [REPO_ROOT, ...packageRoots]; + for (const specifier of specifiers) { + if (specifier.startsWith('.') || specifier.startsWith('@objectstack/') || specifier.startsWith('node:')) continue; + const parts = specifier.split('/'); + const pkgName = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; + // An @types package FIRST: a runtime package that ships no declarations would + // otherwise resolve and then report TS7016, turning "this checkout has no + // types for react" into a census of the documentation. + const typesName = pkgName.startsWith('@') ? `@types/${pkgName.slice(1).replace('/', '__')}` : `@types/${pkgName}`; + for (const root of searchRoots) { + for (const name of [typesName, pkgName]) { + const candidate = join(root, 'node_modules', name); + if (!existsSync(candidate)) continue; + paths[pkgName] = [candidate]; + paths[`${pkgName}/*`] = [join(candidate, '*')]; + break; + } + if (paths[pkgName]) break; + } + } + return paths; +} + +const IMPORT_SPECIFIER_RE = /(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]([^'"]+)['"]/g; +export const specifiersIn = (code) => [...code.matchAll(IMPORT_SPECIFIER_RE)].map((m) => m[1]); + +// ── Compilation ──────────────────────────────────────────────────────────── + +function compilerOptions(paths) { + return { + target: 'ES2022', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + moduleResolution: 'Bundler', + jsx: 'react-jsx', + strict: true, + noEmit: true, + skipLibCheck: true, + esModuleInterop: true, + allowSyntheticDefaultImports: true, + resolveJsonModule: true, + forceConsistentCasingInFileNames: true, + noUnusedLocals: false, + noUnusedParameters: false, + // ⛔ No `baseUrl`: TS 6.0 deprecates it, and every `paths` target below is + // already absolute, so there is nothing for it to be relative to. + typeRoots: [join(REPO_ROOT, 'node_modules', '@types')], + types: ['node'], + paths, + }; +} + +/** + * One program for every block at once. Per-block programs would multiply the + * `.d.ts` closure by a thousand; the `export {}` normalisation is what makes + * one program safe. + */ +function compile(units, packageRoots) { + const tmpDir = mkdtempSync(join(tmpdir(), 'os-md-ts-census-')); + const cleanup = () => { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* a leftover temp dir is not worth failing a census over */ + } + }; + process.on('exit', cleanup); + process.on('SIGINT', () => { + cleanup(); + process.exit(130); + }); + process.on('SIGTERM', () => { + cleanup(); + process.exit(143); + }); + try { + units.forEach((unit, index) => { + const name = `block-${String(index).padStart(4, '0')}.${unit.lang === 'tsx' ? 'tsx' : 'ts'}`; + writeFileSync(join(tmpDir, name), normalise(unit.code)); + unit.compiledAs = name; + }); + const specifiers = new Set(units.flatMap((unit) => specifiersIn(unit.code))); + const paths = { ...workspacePaths(packageRoots), ...externalPaths(specifiers, packageRoots) }; + writeFileSync( + join(tmpDir, 'tsconfig.json'), + JSON.stringify({ compilerOptions: compilerOptions(paths), files: units.map((u) => `./${u.compiledAs}`) }, null, 2), + ); + + // The real binary, the same question #18715's reading asked. `cwd` is the + // temp dir so every diagnostic names a bare `block-NNNN.ts`, and stdout and + // stderr are joined because tsc splits config errors from file ones. + // + // ⚠️ TWO PASSES, and the reason is a measured property of `tsc` itself: + // the CLI reports SYNTACTIC diagnostics and then stops — it never type- + // checks ANY file in a program where one file has a syntax error. One pass + // over this corpus returned 913 TS1xxx and not a single semantic + // diagnostic, the firing control included. FIRING_CONTROL is what caught + // that, which is the entire argument for running it on every census. + // So: pass 1 finds the blocks that do not parse, pass 2 re-runs over the + // ones that do. A block that does not parse is never type-checked, which + // is honest — tsc cannot read it either. + const runTsc = (fileList, configName) => { + writeFileSync( + join(tmpDir, configName), + JSON.stringify({ compilerOptions: compilerOptions(paths), files: fileList.map((n) => `./${n}`) }, null, 2), + ); + const run = spawnSync(process.execPath, [TSC_BIN, '--project', configName, '--pretty', 'false'], { + cwd: tmpDir, + encoding: 'utf8', + maxBuffer: 256 * 1024 * 1024, + }); + if (run.error) throw run.error; + return parseTscOutput(`${run.stdout ?? ''}\n${run.stderr ?? ''}`); + }; + + const all = units.map((u) => u.compiledAs); + const pass1 = runTsc(all, 'tsconfig.json'); + const unparseable = new Set(pass1.diagnostics.filter((d) => d.code >= 1000 && d.code <= SYNTAX_MAX).map((d) => d.file)); + const parseable = all.filter((name) => !unparseable.has(name)); + const pass2 = parseable.length > 0 && parseable.length < all.length + ? runTsc(parseable, 'tsconfig.pass2.json') + : { diagnostics: parseable.length > 0 ? pass1.diagnostics : [], global: [] }; + + const global = [...pass1.global, ...pass2.global]; + const byFile = new Map(units.map((u) => [u.compiledAs, []])); + const seen = new Set(); + for (const d of [...pass1.diagnostics, ...pass2.diagnostics]) { + const key = `${d.file}:${d.line}:${d.code}:${d.message}`; + if (seen.has(key)) continue; + seen.add(key); + const bucket = byFile.get(d.file); + if (bucket) bucket.push(d); + else global.push(d); + } + for (const unit of units) { + unit.diagnostics = (byFile.get(unit.compiledAs) ?? []).map((record) => ({ + ...record, + ...classifyDiagnostic(record), + })); + unit.raw = unit.diagnostics.length > 0; + unit.tolerant = unit.diagnostics.some((d) => !d.forgiven); + unit.elided = hasElisionMarker(unit.code); + const unforgiven = unit.diagnostics.filter((d) => !d.forgiven); + unit.syntaxOnly = unforgiven.length > 0 && unforgiven.every((d) => d.family === 'syntax'); + // Parses as a TypeScript module and STILL fails: the class the #18712 + // `kernel.logger` block belongs to, and the only one a gate could hold + // at zero without anyone first tagging a thousand fragments. + unit.wellFormedAndWrong = unit.tolerant && !unit.diagnostics.some((d) => d.family === 'syntax'); + } + return { global, paths }; + } finally { + cleanup(); + } +} + +// ── Census ───────────────────────────────────────────────────────────────── + +function collectUnits(only) { + let files = populationFiles(); + if (only.length) files = files.filter((f) => only.some((o) => relative(REPO_ROOT, f).includes(o))); + const units = []; + for (const file of files) { + const rel = relative(REPO_ROOT, file); + extractBlocks(readFileSync(file, 'utf8')).forEach((block, index) => { + units.push({ ...block, file: rel, index, kind: 'corpus', stratum: stratumOf(rel), depthOne: isDepthOne(rel) }); + }); + } + return { files, units }; +} + +function controlUnits() { + return [ + { ...extractBlocks(GREEN_CONTROL)[0], file: 'control:green', index: 0, kind: 'control', control: 'green', stratum: 'control' }, + { ...extractBlocks(FIRING_CONTROL)[0], file: 'control:firing', index: 0, kind: 'control', control: 'firing', stratum: 'control' }, + { + ...extractBlocks(UNPUBLISHED_SUBPATH_CONTROL)[0], + file: 'control:unpublished-subpath', + index: 0, + kind: 'control', + control: 'unpublished-subpath', + stratum: 'control', + }, + ]; +} + +/** The refusal list. Empty means the instrument proved it can fire before reporting. */ +export function controlProblems(controls) { + const problems = []; + const green = controls.find((c) => c.control === 'green'); + const firing = controls.find((c) => c.control === 'firing'); + if (!green) problems.push('GREEN_CONTROL did not extract to a block — the extractor is broken.'); + else if (green.raw) { + problems.push( + `GREEN_CONTROL did NOT compile (${green.diagnostics.map((d) => `TS${d.code}`).join(', ')}). ` + + 'A block that must compile did not, so nothing else this run would report is a reading. ' + + 'The usual cause is an unbuilt workspace: run `pnpm build` so each package has the ' + + '`dist/*.d.ts` its own exports map points at, then re-run.', + ); + for (const d of green.diagnostics.slice(0, 4)) problems.push(` GREEN_CONTROL TS${d.code}: ${d.message}`); + } + if (!firing) problems.push('FIRING_CONTROL did not extract to a block — the extractor is broken.'); + else { + if (!firing.raw) { + problems.push( + 'FIRING_CONTROL compiled CLEAN. It reconstructs the pre-#18712 `kernel.logger` block, which measured ' + + 'exit 2 / TS2341. An instrument that cannot reproduce the one failure this family has proven has ' + + 'measured nothing, so this run reports nothing.', + ); + } + if (!firing.diagnostics.some((d) => d.code === 2341)) { + problems.push( + 'FIRING_CONTROL did not produce TS2341 (private member access). It may be failing for an unrelated ' + + `reason — it reported: ${firing.diagnostics.map((d) => `TS${d.code}`).join(', ') || 'nothing'}.`, + ); + } + if (firing.raw && !firing.tolerant) { + problems.push('The tolerance rule FORGAVE the firing control — the rule has been widened until it hides the proven defect.'); + } + if (!firing.elided) { + problems.push( + 'FIRING_CONTROL no longer carries an elision marker, so this run cannot demonstrate why the exclusion ' + + 'reading is disqualified. Restore the `// ... plugin registration code ...` line from PR #18712.', + ); + } + } + const subpath = controls.find((c) => c.control === 'unpublished-subpath'); + if (!subpath) problems.push('UNPUBLISHED_SUBPATH_CONTROL did not extract to a block — the extractor is broken.'); + else { + for (const specifier of UNPUBLISHED_SUBPATH_SPECIFIERS) { + const hit = subpath.diagnostics.find((d) => d.code === 2307 && d.specifier === specifier); + if (!hit) { + problems.push( + `UNPUBLISHED_SUBPATH_CONTROL produced no TS2307 for '${specifier}'. Either the compile path no longer ` + + 'reaches this fixture, or that subpath is now PUBLISHED. If it is published, this control is obsolete ' + + 'for that specifier: repoint it at one that is still in no `exports` entry, in the same edit that ' + + 'records why — ⛔ never by deleting the leg, which removes the guard for the other two with it.', + ); + continue; + } + if (hit.family !== 'unpublished-subpath' || hit.forgiven) { + problems.push( + `UNPUBLISHED_SUBPATH_CONTROL's TS2307 for '${specifier}' classified as ${hit.family} / ` + + `forgiven=${hit.forgiven}, not unpublished-subpath / forgiven=false. The tolerance rule has been ` + + 'widened until a documented import that resolves for NO consumer of the published package is forgiven, ' + + 'which silences this whole family of defect at once.', + ); + } + } + if (subpath.raw && !subpath.tolerant) { + problems.push( + 'The tolerance rule FORGAVE the unpublished-subpath control outright, so every unresolvable ' + + '`@objectstack/*` import in the corpus is now invisible to the TOLERANT reading — the one number a gate ' + + 'could be built on.', + ); + } + } + return problems; +} + +const EMPTY_STRATUM = () => ({ + blocks: 0, raw: 0, tolerant: 0, elided: 0, syntaxOnly: 0, wellFormedAndWrong: 0, + exclusionConsidered: 0, exclusionFail: 0, files: new Set(), filesFailing: new Set(), +}); + +export function summarise(units) { + const strata = {}; + const depthOne = EMPTY_STRATUM(); + const families = {}; + for (const unit of units) { + const bucket = (strata[unit.stratum] ??= EMPTY_STRATUM()); + for (const target of unit.depthOne ? [bucket, depthOne] : [bucket]) { + target.blocks += 1; + target.files.add(unit.file); + if (unit.raw) target.raw += 1; + if (unit.tolerant) { + target.tolerant += 1; + target.filesFailing.add(unit.file); + } + if (unit.elided) target.elided += 1; + if (unit.syntaxOnly) target.syntaxOnly += 1; + if (unit.wellFormedAndWrong) target.wellFormedAndWrong += 1; + if (!unit.elided) { + target.exclusionConsidered += 1; + if (unit.raw) target.exclusionFail += 1; + } + } + for (const d of unit.diagnostics) { + const f = (families[d.family] ??= { diagnostics: 0, blocks: new Set(), codes: {} }); + f.diagnostics += 1; + f.blocks.add(`${unit.file}#${unit.index}`); + f.codes[`TS${d.code}`] = (f.codes[`TS${d.code}`] ?? 0) + 1; + } + } + return { strata, depthOne, families }; +} + +const pct = (n, d) => (d === 0 ? ' n/a' : `${((n / d) * 100).toFixed(1).padStart(5)}%`); + +function reportStratum(label, s) { + console.log(`\n ${label}`); + console.log(` files with TS blocks ......... ${s.files.size}`); + console.log(` fenced ts/typescript/tsx .... ${s.blocks}`); + console.log(` RAW fail ............... ${String(s.raw).padStart(4)} ${pct(s.raw, s.blocks)} (upper bound)`); + console.log(` TOLERANT fail ............... ${String(s.tolerant).padStart(4)} ${pct(s.tolerant, s.blocks)} (lower bound — the gate-able number)`); + console.log(` WELL-FORMED AND WRONG ....... ${String(s.wellFormedAndWrong).padStart(4)} ${pct(s.wellFormedAndWrong, s.blocks)} (parses as a module and still fails — the #18712 class)`); + console.log(` files carrying >=1 tolerant fail: ${s.filesFailing.size}`); + console.log(` blocks whose only unforgiven diagnostics are SYNTAX (would need an explicit \`partial\` tag): ${s.syntaxOnly}`); + console.log(` [disqualified] EXCLUSION reading: ${s.exclusionFail} fail of ${s.exclusionConsidered} considered, ${s.elided} blocks skipped for carrying an elision marker`); +} + +function report(units, controls, summary, files) { + const firing = controls.find((c) => c.control === 'firing'); + console.log('markdown TS block census — package-root Markdown under packages/ (#18715)'); + console.log('\nCONTROLS (they run on every census, so a zero is a reading and not a dead search)'); + console.log(' GREEN_CONTROL compiles clean — `@objectstack/*` resolves through the built exports maps.'); + console.log( + ` FIRING_CONTROL reports ${firing.diagnostics.length} diagnostic(s) including ` + + `TS2341 at block line ${firing.diagnostics.find((d) => d.code === 2341)?.line ?? '?'} — ` + + 'the pre-#18712 `kernel.logger` block, reconstructed from that PR\'s diff, is caught.', + ); + console.log( + ' FIRING_CONTROL also carries an elision marker, so the EXCLUSION reading SKIPS it. ' + + 'That is why exclusion is reported below as disqualified rather than offered.', + ); + console.log( + ` UNPUBLISHED_SUBPATH_CONTROL reports TS2307 on all ${UNPUBLISHED_SUBPATH_SPECIFIERS.length} of its specifiers ` + + `(${UNPUBLISHED_SUBPATH_SPECIFIERS.join(', ')}), every one COUNTED as ` + + 'unpublished-subpath — the tolerance rule still refuses to forgive an import that resolves for no consumer.', + ); + + console.log('\nPOPULATION'); + console.log(` package roots scanned ....... ${findPackageRoots(PACKAGES_DIR).length}`); + console.log(` package-root .md files ...... ${files.length}`); + + for (const [label, key] of [['HAND-WRITTEN (repairable by an author)', 'handwritten'], ['CHANGELOG.md (release-owned history)', 'changelog']]) { + if (summary.strata[key]) reportStratum(label, summary.strata[key]); + } + reportStratum('SUB-STRATUM: the literal packages/*/*.md glob #18715 names', summary.depthOne); + + console.log('\nDIAGNOSTIC FAMILIES (every diagnostic on every corpus block)'); + const rows = Object.entries(summary.families).sort((a, b) => b[1].diagnostics - a[1].diagnostics); + for (const [family, f] of rows) { + const mark = FORGIVEN_FAMILIES.has(family) ? 'forgiven' : 'COUNTED '; + const top = Object.entries(f.codes).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([c, n]) => `${c}x${n}`).join(' '); + console.log(` ${mark} ${family.padEnd(24)} ${String(f.diagnostics).padStart(5)} diagnostics in ${String(f.blocks.size).padStart(4)} blocks ${top}`); + } + + console.log('\nPER FILE — blocks / raw fail / tolerant fail (files with at least one tolerant fail)'); + const perFile = new Map(); + for (const unit of units) { + const row = perFile.get(unit.file) ?? { blocks: 0, raw: 0, tolerant: 0, stratum: unit.stratum }; + row.blocks += 1; + if (unit.raw) row.raw += 1; + if (unit.tolerant) row.tolerant += 1; + perFile.set(unit.file, row); + } + const failing = [...perFile.entries()].filter(([, r]) => r.tolerant > 0).sort((a, b) => b[1].tolerant - a[1].tolerant); + for (const [file, r] of failing) { + console.log(` ${String(r.blocks).padStart(4)} ${String(r.raw).padStart(4)} ${String(r.tolerant).padStart(4)} ${file}`); + } + const clean = [...perFile.entries()].filter(([, r]) => r.tolerant === 0); + console.log(`\n ${clean.length} file(s) with TS blocks and zero tolerant failures.`); + console.log('\n⛔ This is a census. It exits 0 on every outcome and gates nothing.'); +} + +function toJson(units, controls, summary, files) { + const plain = (s) => ({ ...s, files: s.files.size, filesFailing: s.filesFailing.size }); + return { + issue: 18715, + population: { packageRoots: findPackageRoots(PACKAGES_DIR).length, markdownFiles: files.length }, + controls: controls.map((c) => ({ + control: c.control, + diagnostics: c.diagnostics.map((d) => ({ code: d.code, family: d.family, forgiven: d.forgiven, line: d.line })), + elided: c.elided, + tolerantFail: c.tolerant, + })), + strata: Object.fromEntries(Object.entries(summary.strata).map(([k, v]) => [k, plain(v)])), + depthOneSubStratum: plain(summary.depthOne), + families: Object.fromEntries(Object.entries(summary.families).map(([k, v]) => [k, { diagnostics: v.diagnostics, blocks: v.blocks.size, codes: v.codes }])), + blocks: units.map((u) => ({ + file: u.file, index: u.index, lang: u.lang, firstBodyLine: u.firstBodyLine, + stratum: u.stratum, elided: u.elided, declaredPartial: u.declaredPartial, + rawFail: u.raw, tolerantFail: u.tolerant, syntaxOnly: u.syntaxOnly, wellFormedAndWrong: u.wellFormedAndWrong, + diagnostics: u.diagnostics.map((d) => ({ code: d.code, family: d.family, forgiven: d.forgiven, line: d.line, message: d.message })), + })), + }; +} + +// ── Self-test ────────────────────────────────────────────────────────────── +// +// Set as `selfTest()`'s LAST statement, after its verdict prints, and read at +// the dispatch: a `return` above the verdict prints nothing and still exits 0, +// which would report a self-test that never finished as one that passed. +let selfTestReachedVerdict = false; + +// The roster is a LITERAL the table is checked against — ⛔ never derived from +// the table, because `cases.length` moves with the table and a deleted row +// would delete its own floor. Each row LABEL is one battery with a floor of 1; +// `registerCase(c.label)` is the FIRST statement of the loop body, so the floor +// asserts REACH and a row whose guard always skipped would read DID NOT RUN. +// +// SCOPE: these batteries cover the EXTRACTOR, the elision proxy, the +// classifier and the fixtures — everything that needs no compiler. The two +// CONTROLS are deliberately not here: they need a built workspace, and they +// run on every census rather than only when someone types `--self-test`. +// `controlProblems()` is their refusal, and it is not optional either. +const SELF_TEST_BATTERIES = Object.freeze({ + 'extract — a plain ```ts fence is a block': 1, + 'extract — a ```typescript fence is a block': 1, + 'extract — a ```tsx fence is a block': 1, + 'extract — a tilde fence is a block': 1, + 'extract — a non-TypeScript language is not a block': 1, + 'extract — an indented fence is dedented to column 0': 1, + 'extract — a ts fence nested inside another fence is body, not a block': 1, + 'extract — an unterminated fence yields no block': 1, + 'extract — an info string carrying `partial` reads as declared-partial': 1, + 'elision — a trailing `// ... plugin registration code ...` is a marker': 1, + 'elision — ordinary comments are not an elision marker': 1, + 'classify — TS2304 is FORGIVEN (the name the block elided)': 1, + 'classify — TS2341 is COUNTED (private member — the one proven defect)': 1, + 'classify — TS2307 on a relative specifier is FORGIVEN': 1, + 'classify — TS2307 on an @objectstack specifier is COUNTED': 1, + 'classify — TS1005 syntax is COUNTED, never forgiven as elision': 1, + 'classify — TS2593 (a test-runner global) is FORGIVEN as the reader\'s ambient environment': 1, + 'classify — TS7016 (a module that ships no declarations) is FORGIVEN as ambient': 1, + 'externals — an @types package is preferred over the runtime package': 1, + 'tsc output — a located diagnostic line is parsed into file, line, code and message': 1, + 'tsc output — an indented continuation line joins the diagnostic above it': 1, + 'tsc output — a diagnostic with no file (a config error) lands in the global bucket': 1, + 'normalise — `export {}` is appended and body line numbers are unchanged': 1, + 'fixture — FIRING_CONTROL extracts to one block that reads kernel.logger': 1, + 'fixture — FIRING_CONTROL carries the elision marker that disqualifies exclusion': 1, + 'fixture — GREEN_CONTROL extracts to one block importing @objectstack/core': 1, + 'fixture — UNPUBLISHED_SUBPATH_CONTROL extracts to one block whose specifiers are exactly UNPUBLISHED_SUBPATH_SPECIFIERS': 1, + 'fixture — UNPUBLISHED_SUBPATH_CONTROL carries no elision marker, so no reading can skip it': 1, + 'classify — every UNPUBLISHED_SUBPATH_SPECIFIERS entry is COUNTED as unpublished-subpath': 1, + 'refusal — a missing TS2307 on a listed specifier REFUSES the census': 1, + 'refusal — a FORGIVEN unpublished-subpath diagnostic REFUSES the census': 1, + 'population — stratumOf separates CHANGELOG.md from hand-written Markdown': 1, + 'population — isDepthOne separates a depth-1 package root from a nested one': 1, + 'exports — pickTypesTarget reads the types entry under the import condition': 1, + 'exports — a subpath a package does not export gets no mapping': 1, +}); + +// 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 = 35; + +const md = (...lines) => lines.join('\n'); + +function selfTest() { + // Healthy stand-ins for the other two controls, so a refusal battery reads + // only the problems the unpublished-subpath leg contributes — and asserts the + // healthy trio reports NONE before asserting a broken one reports some, since + // a `controlProblems` that always complained would otherwise pass. + const healthyGreen = () => ({ control: 'green', raw: false, tolerant: false, elided: false, diagnostics: [] }); + const healthyFiring = () => ({ + control: 'firing', + raw: true, + tolerant: true, + elided: true, + diagnostics: [{ code: 2341, family: 'semantic', forgiven: false }], + }); + const healthySubpathDiagnostics = () => UNPUBLISHED_SUBPATH_SPECIFIERS.map((specifier) => ({ + code: 2307, specifier, family: 'unpublished-subpath', forgiven: false, + })); + const subpathControl = (diagnostics) => ({ control: 'unpublished-subpath', raw: true, tolerant: true, elided: false, diagnostics }); + + const cases = [ + { label: 'extract — a plain ```ts fence is a block', run: () => (extractBlocks(md('```ts', 'const a = 1;', '```')).length === 1 ? null : 'expected 1 block') }, + { label: 'extract — a ```typescript fence is a block', run: () => (extractBlocks(md('```typescript', 'const a = 1;', '```'))[0]?.lang === 'typescript' ? null : 'expected a typescript block') }, + { label: 'extract — a ```tsx fence is a block', run: () => (extractBlocks(md('```tsx', 'const a = ;', '```'))[0]?.lang === 'tsx' ? null : 'expected a tsx block') }, + { label: 'extract — a tilde fence is a block', run: () => (extractBlocks(md('~~~ts', 'const a = 1;', '~~~')).length === 1 ? null : 'expected 1 block from a tilde fence') }, + { label: 'extract — a non-TypeScript language is not a block', run: () => (extractBlocks(md('```json', '{}', '```')).length === 0 ? null : 'json counted as TypeScript') }, + { + label: 'extract — an indented fence is dedented to column 0', + run: () => { + const b = extractBlocks(md('- item', '', ' ```ts', ' const a = 1;', ' ```'))[0]; + return b && b.code === 'const a = 1;' ? null : `expected dedented code, got ${JSON.stringify(b?.code)}`; + }, + }, + { + label: 'extract — a ts fence nested inside another fence is body, not a block', + run: () => (extractBlocks(md('````md', '```ts', 'const a = 1;', '```', '````')).length === 0 ? null : 'a quoted fence was counted as shipped code'), + }, + { label: 'extract — an unterminated fence yields no block', run: () => (extractBlocks(md('```ts', 'const a = 1;')).length === 0 ? null : 'an unterminated fence produced a block') }, + { label: 'extract — an info string carrying `partial` reads as declared-partial', run: () => (extractBlocks(md('```ts partial', 'const a = 1;', '```'))[0]?.declaredPartial === true ? null : 'the partial tag was not read') }, + { label: 'elision — a trailing `// ... plugin registration code ...` is a marker', run: () => (hasElisionMarker('const a = 1;\n// ... plugin registration code ...') ? null : 'marker missed') }, + { label: 'elision — ordinary comments are not an elision marker', run: () => (hasElisionMarker('// build the kernel\nconst a = 1;') ? 'an ordinary comment read as an elision' : null) }, + { label: 'classify — TS2304 is FORGIVEN (the name the block elided)', run: () => (classifyDiagnostic({ code: 2304, message: "Cannot find name 'kernel'." }).forgiven ? null : 'TS2304 not forgiven') }, + { label: 'classify — TS2341 is COUNTED (private member — the one proven defect)', run: () => (classifyDiagnostic({ code: 2341, message: "Property 'logger' is private." }).forgiven ? 'TS2341 forgiven — the proven defect would be invisible' : null) }, + { label: 'classify — TS2307 on a relative specifier is FORGIVEN', run: () => (classifyDiagnostic({ code: 2307, message: "Cannot find module './my-kernel' or its corresponding type declarations." }).forgiven ? null : 'a doc-local path was counted') }, + { label: 'classify — TS2307 on an @objectstack specifier is COUNTED', run: () => (classifyDiagnostic({ code: 2307, message: "Cannot find module '@objectstack/core/plugin' or its corresponding type declarations." }).forgiven ? 'an unpublished subpath was forgiven' : null) }, + { label: 'classify — TS1005 syntax is COUNTED, never forgiven as elision', run: () => (classifyDiagnostic({ code: 1005, message: "';' expected." }).family === 'syntax' && !classifyDiagnostic({ code: 1005, message: '' }).forgiven ? null : 'syntax forgiven') }, + { label: "classify — TS2593 (a test-runner global) is FORGIVEN as the reader's ambient environment", run: () => (classifyDiagnostic({ code: 2593, message: "Cannot find name 'it'." }).family === 'missing-ambient-environment' ? null : 'a test-runner global was counted against the document') }, + { label: 'classify — TS7016 (a module that ships no declarations) is FORGIVEN as ambient', run: () => (classifyDiagnostic({ code: 7016, message: "Could not find a declaration file for module 'react/jsx-runtime'." }).forgiven ? null : 'a missing third-party declaration was counted against the document') }, + { + label: 'tsc output — a located diagnostic line is parsed into file, line, code and message', + run: () => { + const { diagnostics } = parseTscOutput("block-0007.ts(13,45): error TS2341: Property 'logger' is private and only accessible within class 'ObjectKernel'."); + const d = diagnostics[0]; + if (!d) return 'nothing parsed'; + return d.file === 'block-0007.ts' && d.line === 13 && d.code === 2341 && d.message.startsWith("Property 'logger' is private") + ? null + : `parsed ${JSON.stringify(d)}`; + }, + }, + { + label: 'tsc output — an indented continuation line joins the diagnostic above it', + run: () => { + const { diagnostics } = parseTscOutput('block-0001.ts(3,1): error TS2322: Type A is not assignable to type B.\n Property x is missing.'); + return diagnostics.length === 1 && diagnostics[0].message.includes('Property x is missing.') + ? null + : `got ${diagnostics.length} diagnostic(s): ${JSON.stringify(diagnostics.map((d) => d.message))}`; + }, + }, + { + label: 'tsc output — a diagnostic with no file (a config error) lands in the global bucket', + run: () => { + const { diagnostics, global } = parseTscOutput("error TS5101: Option 'baseUrl' is deprecated."); + return diagnostics.length === 0 && global.length === 1 && global[0].code === 5101 + ? null + : `diagnostics=${diagnostics.length} global=${JSON.stringify(global)}`; + }, + }, + { + label: 'externals — an @types package is preferred over the runtime package', + run: () => { + const dir = mkdtempSync(join(tmpdir(), 'os-md-ts-selftest-')); + try { + mkdirSync(join(dir, 'node_modules', '@types', 'reactish'), { recursive: true }); + mkdirSync(join(dir, 'node_modules', 'reactish'), { recursive: true }); + const paths = externalPaths(['reactish/jsx-runtime'], [dir]); + return paths.reactish?.[0]?.includes(`${'@types'}/reactish`) ? null : `mapped to ${paths.reactish?.[0]}`; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + }, + { + label: 'normalise — `export {}` is appended and body line numbers are unchanged', + run: () => { + const b = extractBlocks(md('# t', '', '```ts', 'const a = 1;', '```'))[0]; + if (b.firstBodyLine !== 4) return `firstBodyLine ${b.firstBodyLine}, expected 4`; + const n = normalise(b.code); + return n.startsWith('const a = 1;') && n.includes('export {};') ? null : 'normalisation changed the head of the block'; + }, + }, + { + label: 'fixture — FIRING_CONTROL extracts to one block that reads kernel.logger', + run: () => { + const b = extractBlocks(FIRING_CONTROL); + if (b.length !== 1) return `expected 1 block, got ${b.length}`; + return b[0].code.includes('kernel.logger') ? null : 'the fixture no longer reaches into kernel.logger'; + }, + }, + { label: 'fixture — FIRING_CONTROL carries the elision marker that disqualifies exclusion', run: () => (hasElisionMarker(extractBlocks(FIRING_CONTROL)[0].code) ? null : 'the fixture lost its elision marker, and with it the exclusion argument') }, + { + label: 'fixture — GREEN_CONTROL extracts to one block importing @objectstack/core', + run: () => { + const b = extractBlocks(GREEN_CONTROL); + if (b.length !== 1) return `expected 1 block, got ${b.length}`; + return specifiersIn(b[0].code).includes('@objectstack/core') ? null : 'the green control stopped importing anything'; + }, + }, + { + label: 'fixture — UNPUBLISHED_SUBPATH_CONTROL extracts to one block whose specifiers are exactly UNPUBLISHED_SUBPATH_SPECIFIERS', + run: () => { + const b = extractBlocks(UNPUBLISHED_SUBPATH_CONTROL); + if (b.length !== 1) return `expected 1 block, got ${b.length}`; + const found = specifiersIn(b[0].code); + const expected = [...UNPUBLISHED_SUBPATH_SPECIFIERS]; + return found.length === expected.length && expected.every((s) => found.includes(s)) + ? null + : `the block imports ${JSON.stringify(found)} but the roster is ${JSON.stringify(expected)} — editing one without the other narrows the guard silently`; + }, + }, + { + label: 'fixture — UNPUBLISHED_SUBPATH_CONTROL carries no elision marker, so no reading can skip it', + run: () => (hasElisionMarker(extractBlocks(UNPUBLISHED_SUBPATH_CONTROL)[0].code) + ? 'the fixture gained an elision marker, so the exclusion reading would skip the one leg guarding this family' + : null), + }, + { + label: 'classify — every UNPUBLISHED_SUBPATH_SPECIFIERS entry is COUNTED as unpublished-subpath', + run: () => { + for (const specifier of UNPUBLISHED_SUBPATH_SPECIFIERS) { + const verdict = classifyDiagnostic({ + code: 2307, + message: `Cannot find module '${specifier}' or its corresponding type declarations.`, + }); + if (verdict.family !== 'unpublished-subpath' || verdict.forgiven) { + return `${specifier} classified ${verdict.family} / forgiven=${verdict.forgiven}`; + } + } + return null; + }, + }, + { + label: 'refusal — a missing TS2307 on a listed specifier REFUSES the census', + run: () => { + const healthy = controlProblems([healthyGreen(), healthyFiring(), subpathControl(healthySubpathDiagnostics())]); + if (healthy.length !== 0) return `the healthy control trio already reported ${healthy.length} problem(s): ${healthy[0]}`; + const dropped = UNPUBLISHED_SUBPATH_SPECIFIERS[1]; + const problems = controlProblems([ + healthyGreen(), + healthyFiring(), + subpathControl(healthySubpathDiagnostics().filter((d) => d.specifier !== dropped)), + ]); + return problems.some((p) => p.includes(dropped)) ? null : `dropping ${dropped} produced ${JSON.stringify(problems)}`; + }, + }, + { + label: 'refusal — a FORGIVEN unpublished-subpath diagnostic REFUSES the census', + run: () => { + const target = UNPUBLISHED_SUBPATH_SPECIFIERS[2]; + const widened = healthySubpathDiagnostics().map((d) => (d.specifier === target + ? { ...d, family: 'external-not-installed', forgiven: true } + : d)); + const problems = controlProblems([healthyGreen(), healthyFiring(), subpathControl(widened)]); + return problems.some((p) => p.includes(target) && p.includes('forgiven=true')) + ? null + : `forgiving ${target} produced ${JSON.stringify(problems)}`; + }, + }, + { label: 'population — stratumOf separates CHANGELOG.md from hand-written Markdown', run: () => (stratumOf('packages/core/CHANGELOG.md') === 'changelog' && stratumOf('packages/core/README.md') === 'handwritten' ? null : 'stratum split broken') }, + { label: 'population — isDepthOne separates a depth-1 package root from a nested one', run: () => (isDepthOne('packages/core/README.md') && !isDepthOne('packages/plugins/plugin-auth/README.md') ? null : 'depth-1 sub-stratum broken') }, + { + label: 'exports — pickTypesTarget reads the types entry under the import condition', + run: () => { + const target = pickTypesTarget({ browser: { import: { types: './dist/browser.d.mts' } }, import: { types: './dist/index.d.mts', default: './dist/index.mjs' }, require: { types: './dist/index.d.ts' } }); + return target === './dist/index.d.mts' ? null : `picked ${target}`; + }, + }, + { + label: 'exports — a subpath a package does not export gets no mapping', + run: () => { + const dir = mkdtempSync(join(tmpdir(), 'os-md-ts-selftest-')); + try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: '@x/y', exports: { '.': { types: './dist/index.d.ts' } } })); + const paths = workspacePaths([dir]); + return paths['@x/y'] && !paths['@x/y/secret'] ? null : 'an unexported subpath was mapped anyway'; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + }, + ]; + + let failed = 0; + const batterySeen = new Map(); + const registerCase = (name) => batterySeen.set(name, (batterySeen.get(name) ?? 0) + 1); + for (const c of cases) { + registerCase(c.label); + let verdict; + try { + verdict = c.run(); + } catch (error) { + verdict = `threw: ${error.message}`; + } + if (verdict) { + failed += 1; + console.error(` ✗ ${c.label}\n ${verdict}`); + } + } + + const declared = Object.keys(SELF_TEST_BATTERIES); + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + failed += 1; + console.error(` ✗ SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`); + } + for (const [name, count] of batterySeen) { + if (name in SELF_TEST_BATTERIES) continue; + failed += 1; + console.error(` ✗ battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES — a case attributed to no declared battery is one nothing floors.`); + } + for (const name of declared) { + const count = batterySeen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + failed += 1; + console.error(count === 0 + ? ` ✗ battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned.` + : ` ✗ battery "${name}" registered ${count} case(s), below its pinned floor of ${SELF_TEST_BATTERIES[name]}.`); + } + + if (failed > 0) { + console.error(`\n✗ measure-markdown-ts-blocks self-test: ${failed} failure(s) (cases and floor).`); + process.exit(1); + } + console.log(`\n✓ measure-markdown-ts-blocks self-test: ${cases.length} cases pass, ${declared.length} batteries at or above floor.`); + selfTestReachedVerdict = true; +} + +// ── Dispatch ─────────────────────────────────────────────────────────────── + +function main() { + const argv = process.argv.slice(2); + + if (argv.includes('--self-test')) { + selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ measure-markdown-ts-blocks self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test that never\n' + + 'finished as a self-test that passed.\n', + ); + process.exit(1); + } + return; + } + + const wantJson = argv.includes('--json'); + const controlsOnly = argv.includes('--controls-only'); + const only = argv.flatMap((arg, i) => (arg === '--only' && argv[i + 1] ? [argv[i + 1]] : [])); + + const packageRoots = findPackageRoots(PACKAGES_DIR); + const { files, units } = controlsOnly ? { files: [], units: [] } : collectUnits(only); + const controls = controlUnits(); + const { global } = compile([...controls, ...units], packageRoots); + + const problems = controlProblems(controls); + for (const g of global) problems.push(`compiler option diagnostic TS${g.code}: ${g.message}`); + if (problems.length > 0) { + console.error('✗ measure-markdown-ts-blocks REFUSES to report a census:\n'); + for (const p of problems) console.error(` ${p}`); + console.error( + '\n"could not run" is a failure, not a skip. A census that degraded to "0 failures" because\n' + + 'nothing resolved would be the worst output this instrument could produce.\n', + ); + process.exit(1); + } + + const summary = summarise(units); + if (wantJson) console.log(JSON.stringify(toJson(units, controls, summary, files), null, 2)); + else if (controlsOnly) { + console.log( + '✓ all three controls behaved: GREEN compiles, FIRING reports TS2341 and survives the tolerance rule, ' + + 'UNPUBLISHED_SUBPATH reports a COUNTED TS2307 on each of its three specifiers.', + ); + } + else report(units, controls, summary, files); +} + +// Guarded so this file can be imported for its exports without running the +// census inside the importer (`scripts/invoked-as.mjs`, `check:entry-guard`). +if (isEntrypoint(import.meta.url)) main(); diff --git a/scripts/measure-reserved-identity-name-census.mjs b/scripts/measure-reserved-identity-name-census.mjs new file mode 100644 index 00000000000..53143f425e1 --- /dev/null +++ b/scripts/measure-reserved-identity-name-census.mjs @@ -0,0 +1,400 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// measure-reserved-identity-name-census -- the #15972 census instrument. +// +// node scripts/measure-reserved-identity-name-census.mjs # declarations +// node scripts/measure-reserved-identity-name-census.mjs --json # machine +// node scripts/measure-reserved-identity-name-census.mjs --self-test # controls only +// node scripts/measure-reserved-identity-name-census.mjs --rows FILE # a deployment's rows +// node scripts/measure-reserved-identity-name-census.mjs --rows-schema # the export shape +// +// READ-ONLY, and that is the ruling, not a preference. Maintainer ruling +// (director seat, summon #20, decision batch #105 item 4, 2026-09-09) on what +// happens to rows that ALREADY collide, verbatim: +// +// 「refuse new writes only. No migration. A read-only census reports existing +// colliding rows to the maintainer; nothing rewrites them (option B +// refused: stored-data migration is the manual floor's own item).」 +// +// So this script opens no database connection, takes no credentials, and its +// `--rows` mode consumes a FILE the operator exported. There is no code path +// here that writes anything anywhere. It is NOT a gate: not wired into any +// workflow, exits 0 on any collision count, and deliberately not named +// `check:*` / `gen:*` so the #4203 script ledger has nothing to classify -- +// the shape `measure-position-name-fold-census.mjs` established. +// +// The only non-zero exits are a failing self-test and an unreadable/malformed +// `--rows` input. +// +// ## What collides, and why the two populations are reported separately +// +// ADR-0068 D2 reserves four names for the framework's built-in identities. +// Since #15972 the write path refuses them on both position doors, so the +// populations below can only be rows that PREDATE the guard: +// +// - `sys_position` rows whose `name` spells a reserved name while their +// `managed_by` is NOT the platform's own provenance. The platform seeds +// these four names per organization on purpose (`bootstrapBuiltinRoles`, +// `managed_by: 'platform'`); those rows are the catalog and are NOT +// collisions. A row with any other provenance is a tenant or package +// definition standing on a reserved name. +// - `sys_user_position` rows whose `position` spells a reserved name. ⚠️ ALL +// of them are collisions -- no writer in any package creates one, so there +// is no legitimate population to subtract. This is the row the card is +// about: it is what makes a plain member LOOK like a built-in identity to +// any reader that reads the name instead of the capability rung. +// +// ## The reserved set is READ from the spec constant, never retyped +// +// The ruling closes the enumeration: 「exactly the ADR-0068 built-in identity +// names, read from the spec constant that declares them (closed enumeration, +// ⛔ not retyped, ⛔ not widened to `org_*` shapes by pattern)」. A census that +// carried its own copy of the four strings would answer a different question +// from the guard it audits the day the constant moves, so the names are parsed +// out of `packages/spec/src/identity/eval-user.zod.ts`. +// +// ⚠️ A parse that silently found nothing would report a comfortable ZERO over +// every population. So the parse has a CONTROL that must fire ({@link +// readReservedNames} throws when it does not), and `--self-test` asserts it. +// +// ## What this instrument CANNOT see, stated up front +// +// The declaration census reads THIS REPOSITORY. Positions and assignments are +// RUNTIME rows: an operator who created a position in Setup, or an integration +// that wrote an assignment row, produces a collision no static census can ever +// see. That population is reachable only through `--rows`, which is why +// `--rows` refuses to call an empty input "zero" -- and why a zero from the +// default mode prints the sentence saying so, every time. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..'); +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next']); +const SPEC_DECL = 'packages/spec/src/identity/eval-user.zod.ts'; + +/** Provenance values that mean "the platform's own catalog row" (A4 #2920 plus its legacy spelling). */ +const PLATFORM_PROVENANCE = new Set(['platform', 'system']); + +/* ------------------------------------------------------------------------- * + * The reserved set — parsed out of the declaring constant + * ------------------------------------------------------------------------- */ + +/** + * The four ADR-0068 D2 names, read from the `BUILTIN_IDENTITY_NAMES` array in + * the spec and resolved through the `BUILTIN_IDENTITY_*` constants it lists. + * + * Throws rather than returning `[]`: a census that cannot find its own subject + * must say so, because the alternative is a zero that reads like an all-clear. + */ +export function readReservedNames(source = read(SPEC_DECL)) { + const consts = new Map(); + const constRe = /export const (BUILTIN_IDENTITY_[A-Z0-9_]+)\s*=\s*'([^']*)'/g; + let m; + while ((m = constRe.exec(source)) !== null) consts.set(m[1], m[2]); + + const arrayRe = /export const BUILTIN_IDENTITY_NAMES\s*=\s*\[([^\]]*)\]/; + const arr = arrayRe.exec(source); + if (!arr) throw new Error(`census control FAILED: no BUILTIN_IDENTITY_NAMES array in ${SPEC_DECL}`); + + const names = []; + for (const raw of arr[1].split(',')) { + const ident = raw.trim(); + if (!ident) continue; + const literal = /^'([^']*)'$/.exec(ident); + if (literal) { names.push(literal[1]); continue; } + if (!consts.has(ident)) { + throw new Error(`census control FAILED: ${ident} is listed in BUILTIN_IDENTITY_NAMES but never declared in ${SPEC_DECL}`); + } + names.push(consts.get(ident)); + } + if (names.length === 0) throw new Error(`census control FAILED: BUILTIN_IDENTITY_NAMES parsed empty in ${SPEC_DECL}`); + return names; +} + +/* ------------------------------------------------------------------------- * + * Corpus scan (declarations) + * ------------------------------------------------------------------------- */ + +function read(rel) { + try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return ''; } +} + +function walk(root, exts, out = []) { + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (e.name.startsWith('.')) continue; + const full = path.join(root, e.name); + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) continue; + walk(full, exts, out); + } else if (exts.has(path.extname(e.name))) { + out.push(path.relative(REPO_ROOT, full)); + } + } + return out; +} + +/** + * Every `name: ''` / `position: ''` STRING LITERAL in a + * declaration under `packages/`, `examples/` or `apps/`. + * + * Textual, and bounded on purpose (the sibling census records the same + * reasoning): the failure mode being hunted is an authored literal, and the + * price of a source scan is that it sees only the spellings it knows. It does + * NOT see a name assembled at runtime, and it does not know which key belongs + * to a position declaration versus something else that happens to have a + * `name:` -- so its hits are CANDIDATES a reader adjudicates, never verdicts. + */ +export function scanDeclarations(reserved, files) { + const hits = []; + const alternation = reserved.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + const re = new RegExp(`\\b(name|position)\\s*:\\s*'(${alternation})'`, 'g'); + for (const rel of files) { + const src = read(rel); + if (!src) continue; + let m; + re.lastIndex = 0; + while ((m = re.exec(src)) !== null) { + hits.push({ file: rel, line: src.slice(0, m.index).split('\n').length, key: m[1], name: m[2] }); + } + } + return hits; +} + +/* ------------------------------------------------------------------------- * + * Deployment rows (`--rows`) + * ------------------------------------------------------------------------- */ + +const ROWS_SCHEMA = ` +--rows FILE expects JSON exported READ-ONLY from the deployment: + + { + "deployment": "", + "sys_position": [ { "id": …, "name": …, "managed_by": …, "organization_id": … }, … ], + "sys_user_position": [ { "id": …, "user_id": …, "position": …, "organization_id": … }, … ] + } + +Both arrays are REQUIRED (an absent one is refused, not read as empty — see +below). Export them with reads only, e.g. against the data API: + + GET /api/v1/data/sys_position?fields=id,name,managed_by,organization_id + GET /api/v1/data/sys_user_position?fields=id,user_id,position,organization_id + +⚠️ An EMPTY array is accepted and reported as zero FOR THAT TABLE; a MISSING +key is refused. The distinction is the whole point: "we exported it and there +were none" and "we never exported it" must not produce the same all-clear. +`.trim(); + +export function censusRows(reserved, payload) { + const problems = []; + for (const table of ['sys_position', 'sys_user_position']) { + if (!Array.isArray(payload?.[table])) problems.push(`'${table}' is missing or not an array`); + } + if (problems.length) { + const err = new Error(`--rows input is not a census: ${problems.join('; ')}`); + err.schema = ROWS_SCHEMA; + throw err; + } + const reservedSet = new Set(reserved); + + const positions = payload.sys_position + .filter((r) => reservedSet.has(String(r?.name ?? ''))) + .map((r) => ({ + id: r?.id ?? null, + name: String(r?.name), + managed_by: r?.managed_by ?? null, + organization_id: r?.organization_id ?? null, + // The platform's own catalog rows are not collisions — they ARE the + // built-in identity catalog, seeded per organization by design. + platformCatalog: PLATFORM_PROVENANCE.has(String(r?.managed_by ?? '')), + })); + + const assignments = payload.sys_user_position + .filter((r) => reservedSet.has(String(r?.position ?? ''))) + .map((r) => ({ + id: r?.id ?? null, + user_id: r?.user_id ?? null, + position: String(r?.position), + organization_id: r?.organization_id ?? null, + })); + + return { + deployment: payload.deployment ?? null, + scanned: { sys_position: payload.sys_position.length, sys_user_position: payload.sys_user_position.length }, + positionCollisions: positions.filter((p) => !p.platformCatalog), + platformCatalogRows: positions.filter((p) => p.platformCatalog), + assignmentCollisions: assignments, + }; +} + +/* ------------------------------------------------------------------------- * + * Self-test — the controls, so a zero can be trusted + * ------------------------------------------------------------------------- */ + +function selfTest() { + const failures = []; + const check = (label, fn) => { + try { fn(); } catch (e) { failures.push(`${label}: ${e.message}`); } + }; + const eq = (a, b, what) => { + const [x, y] = [JSON.stringify(a), JSON.stringify(b)]; + if (x !== y) throw new Error(`${what}: expected ${y}, got ${x}`); + }; + + // 1. The reserved set is found in the real declaration, and it is the four. + check('reserved set parses from the spec', () => { + const names = readReservedNames(); + eq(names.length, 4, 'reserved name count'); + for (const n of names) if (!/^[a-z][a-z0-9_]*$/.test(n)) throw new Error(`not a machine name: ${n}`); + }); + + // 2. THE CONTROL THAT MATTERS — a broken parse must THROW, never report zero. + check('a declaration with no array is refused, not read as empty', () => { + let threw = false; + try { readReservedNames("export const BUILTIN_IDENTITY_PLATFORM_ADMIN = 'platform_admin';"); } + catch { threw = true; } + if (!threw) throw new Error('a source with no BUILTIN_IDENTITY_NAMES array was accepted'); + }); + check('a name listed but never declared is refused', () => { + let threw = false; + try { readReservedNames('export const BUILTIN_IDENTITY_NAMES = [\n BUILTIN_IDENTITY_GHOST,\n] as const;'); } + catch { threw = true; } + if (!threw) throw new Error('an undeclared member was accepted'); + }); + + // 3. The declaration scanner FIRES on a positive control and stays silent on + // the near-misses the ruling refuses to widen to. + check('the declaration scanner fires, and does not widen by pattern', () => { + const reserved = ['platform_admin', 'org_admin']; + const tmp = path.join(REPO_ROOT, 'packages/spec/src/identity/eval-user.zod.ts'); + if (!fs.existsSync(tmp)) throw new Error('control corpus file is missing'); + const hits = scanDeclarations(reserved, [SPEC_DECL]); + // The spec file declares the metadata map keyed by the constants, not by + // literals, so this control is about the SCANNER, run over a synthetic + // corpus below rather than over that file's spelling. + const synthetic = scanSynthetic(reserved, [ + "const a = { name: 'platform_admin' };", + "const b = { position: 'org_admin' };", + "const c = { name: 'org_manager' };", // ⛔ must NOT match + "const d = { name: 'platform_admin_x' };", // ⛔ must NOT match + "const e = { label: 'platform_admin' };", // ⛔ wrong key + ]); + eq(synthetic.map((h) => h.name), ['platform_admin', 'org_admin'], 'synthetic scanner hits'); + if (!Array.isArray(hits)) throw new Error('scanner did not return an array'); + }); + + // 4. `--rows` separates the platform catalog from a real collision, and + // refuses an input that never exported a table. + check('rows census: catalog rows are not collisions, assignments always are', () => { + const out = censusRows(['platform_admin', 'org_admin'], { + sys_position: [ + { id: 'p1', name: 'platform_admin', managed_by: 'platform' }, + { id: 'p2', name: 'platform_admin', managed_by: 'system' }, + { id: 'p3', name: 'org_admin', managed_by: 'admin' }, + { id: 'p4', name: 'sales_manager', managed_by: 'admin' }, + ], + sys_user_position: [ + { id: 'a1', user_id: 'u1', position: 'platform_admin' }, + { id: 'a2', user_id: 'u2', position: 'sales_manager' }, + ], + }); + eq(out.positionCollisions.map((r) => r.id), ['p3'], 'position collisions'); + eq(out.platformCatalogRows.map((r) => r.id), ['p1', 'p2'], 'platform catalog rows'); + eq(out.assignmentCollisions.map((r) => r.id), ['a1'], 'assignment collisions'); + }); + check('rows census: a MISSING table is refused, an EMPTY one is zero', () => { + let threw = false; + try { censusRows(['platform_admin'], { sys_position: [] }); } catch { threw = true; } + if (!threw) throw new Error('an input missing sys_user_position was accepted'); + const out = censusRows(['platform_admin'], { sys_position: [], sys_user_position: [] }); + eq(out.assignmentCollisions.length, 0, 'empty export'); + }); + + if (failures.length) { + console.error('SELF-TEST FAILED'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); + } + console.log(`self-test OK — ${5} controls, all firing`); +} + +/** Scanner over in-memory lines, for the self-test's synthetic corpus. */ +function scanSynthetic(reserved, lines) { + const alternation = reserved.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + const re = new RegExp(`\\b(name|position)\\s*:\\s*'(${alternation})'`, 'g'); + const hits = []; + for (const line of lines) { + let m; + re.lastIndex = 0; + while ((m = re.exec(line)) !== null) hits.push({ key: m[1], name: m[2] }); + } + return hits; +} + +/* ------------------------------------------------------------------------- * + * CLI + * ------------------------------------------------------------------------- */ + +const ZERO_CAVEAT = + '⚠️ A zero here is a zero over DECLARATIONS in this repository, never over a deployment. ' + + 'Positions and assignments are runtime rows; use --rows with an export to census a live store.'; + +function main(argv) { + if (argv.includes('--self-test')) return selfTest(); + if (argv.includes('--rows-schema')) { console.log(ROWS_SCHEMA); return; } + + const json = argv.includes('--json'); + const reserved = readReservedNames(); + + const rowsAt = argv.indexOf('--rows'); + if (rowsAt !== -1) { + const file = argv[rowsAt + 1]; + if (!file) { console.error('--rows needs a FILE'); process.exit(2); } + let payload; + try { payload = JSON.parse(fs.readFileSync(file, 'utf8')); } + catch (e) { console.error(`--rows: cannot read ${file}: ${e.message}`); process.exit(2); } + let out; + try { out = censusRows(reserved, payload); } + catch (e) { console.error(e.message); if (e.schema) console.error(`\n${e.schema}`); process.exit(2); } + + if (json) { console.log(JSON.stringify({ mode: 'rows', reserved, ...out }, null, 2)); return; } + console.log(`# reserved identity-name census — deployment rows${out.deployment ? ` (${out.deployment})` : ''}`); + console.log(`reserved set: ${reserved.join(', ')}`); + console.log(`scanned: ${out.scanned.sys_position} sys_position, ${out.scanned.sys_user_position} sys_user_position`); + console.log(`\n## sys_position rows standing on a reserved name (excluding the platform catalog): ${out.positionCollisions.length}`); + for (const r of out.positionCollisions) { + console.log(` - ${r.id} name=${r.name} managed_by=${r.managed_by} organization=${r.organization_id}`); + } + console.log(`\n## sys_user_position rows spelling a reserved name — ALL are collisions: ${out.assignmentCollisions.length}`); + for (const r of out.assignmentCollisions) { + console.log(` - ${r.id} user=${r.user_id} position=${r.position} organization=${r.organization_id}`); + } + console.log(`\n(platform catalog rows seen and NOT counted: ${out.platformCatalogRows.length})`); + console.log('\n⛔ Nothing here is rewritten. Renaming or removing a listed row is a maintainer decision.'); + return; + } + + const files = [ + ...walk(path.join(REPO_ROOT, 'packages'), new Set(['.ts', '.tsx'])), + ...walk(path.join(REPO_ROOT, 'examples'), new Set(['.ts', '.tsx'])), + ...walk(path.join(REPO_ROOT, 'apps'), new Set(['.ts', '.tsx'])), + ].filter((f) => !f.includes('.test.') && !f.includes('.spec.')); + const hits = scanDeclarations(reserved, files); + + if (json) { console.log(JSON.stringify({ mode: 'declarations', reserved, scannedFiles: files.length, hits, caveat: ZERO_CAVEAT }, null, 2)); return; } + console.log('# reserved identity-name census — declarations in this repository'); + console.log(`reserved set: ${reserved.join(', ')} (read from ${SPEC_DECL})`); + console.log(`scanned: ${files.length} non-test source files`); + console.log(`\ncandidate declarations spelling a reserved name: ${hits.length}`); + for (const h of hits) console.log(` - ${h.file}:${h.line} ${h.key}: '${h.name}'`); + console.log(`\n${ZERO_CAVEAT}`); +} + +if (isEntrypoint(import.meta.url)) main(process.argv.slice(2)); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 8434ef408ac..e252ceeb9ec 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -6006,8 +6006,7 @@ export function packageRootAnchoredHint(hint, base, tree, files) { * false: `node_modules`, `dist`, `coverage` and `.turbo` off * check-refd-timer-probe's skip set; `.changeset` twice — off * check-keyed-text-bounds' `SKIP_DIRS` and off the noise floor the card was - * filed on; the two gate paths measurement-claim-triage declared it skips (that - * tool is removed from the tree, held at 7111e14ac9); and + * filed on; the two gate paths measurement-claim-triage declares it skips; and * `scripts/**`, `docs/adr/**` and `.changeset/**` off `DEFERRED_SURFACES`, * which is what the `DEFERRED` arm retired. The changeset pair is what a dev * actually saw when #15753 was filed: a card that has not written its diff --git a/scripts/pm/measurement-claim-triage.mjs b/scripts/pm/measurement-claim-triage.mjs new file mode 100644 index 00000000000..1514945822c --- /dev/null +++ b/scripts/pm/measurement-claim-triage.mjs @@ -0,0 +1,653 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Tree-measurement claims under `scripts/**` — the CLASSIFIER (#17797). + * + * node scripts/pm/measurement-claim-triage.mjs the population + every verdict + * node scripts/pm/measurement-claim-triage.mjs --review the kind-3 residue alone + * node scripts/pm/measurement-claim-triage.mjs --self-test the controls, both directions + * + * ## Why a tool and not a sentence + * + * The card that asked for this states its own population as a count. That count + * was taken once and then decayed — which is the very defect the card is about, + * reproduced by the card. ⛔ So no number describing this population is written + * down here. The scope line this tool prints IS the number, re-derived on the + * tree it is run against, and a reader who wants the population runs the tool. + * ⭐ The figures are deliberately absent from this docblock; ⛔ do not helpfully + * restore one. + * + * ## The three kinds, and why one phrase carries all of them + * + * A sentence that reports a measurement of this repository is one of three + * things, and they take DIFFERENT repairs: + * + * KIND 1 a citation of a past measurement. It is scoped to a moment — by a + * revision, a date, or a named event ("before this gate was written", + * "at the commit that took it") or a counterfactual condition ("with + * the reservation removed"). It reads as history, so it stays true + * forever. ⇒ NEEDS NOTHING. + * + * KIND 2 a claim whose failure direction is NAMED, and which something in + * the tree announces when it moves — a zero whose falsifier is + * spelled out, a figure a gate reprints, a figure a pin reds on. + * It does not decay SILENTLY, which is the whole harm. ⇒ NEEDS + * NOTHING. + * + * KIND 3 a bare magnitude in the present tense: no moment, no named + * direction, nothing that speaks when it moves. It reads as current + * state and goes false in silence. ⇒ THIS IS THE DEFECT. + * + * ⛔ The phrase alone separates none of them, which is why the population below + * is an UPPER BOUND on a population and NOT a count of defects. Treating the + * sweep's size as a finding count is the error this tool exists to prevent. + * + * ## What is mechanical here, and what is not + * + * Tests T0–T3 run over source text and are recall filters: each one can only + * move a hit OUT of the review residue, and each prints the cue that moved it, + * so a verdict is always auditable against the sentence. What no regex decides + * is T4 — whether a reader acts on the magnitude's LEVEL or on a relation the + * level's drift survives. T4 is a judgement, so it is recorded per site in + * `TRIAGE` below, with its reason, exactly as the bare-root worklist records + * its own. A review hit with no `TRIAGE` row prints as UNTRIAGED and reds the + * self-test: a new claim of this shape has to be judged by someone. + * + * ## Scope, and the file deliberately outside it + * + * `scripts/pm/dispatch-gates.mjs` is EXCLUDED, with its reason carried as data + * in `EXCLUDED` and printed on every run rather than left in prose. The residue + * it holds is therefore un-swept, and the scope line says so instead of reading + * as complete. + * + * ## ⚠️ This tool is not wired into CI + * + * Wiring a `--self-test` needs a `package.json` script and a workflow step, and + * both sit outside the file surface this card was dispatched with. Until that + * lands, `--self-test` is run by hand and its controls are not watching + * anything on their own. ⛔ Do not read a green run here as CI coverage. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from '../invoked-as.mjs'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..'); +const SCAN_ROOT = join(REPO_ROOT, 'scripts'); + +/** This file's own path, relative to the repo root, in the spelling verdicts use. */ +const SELF = 'scripts/pm/measurement-claim-triage.mjs'; + +/** + * FORM A — the phrase the card names, in every spelling it actually occurs in + * on this tree. ⚠️ The card's own probe was case-sensitive and lower-case only; + * on this tree that spelling reaches strictly fewer sites than the sentence + * does, and the gap is not a rounding error. The count either way is printed, + * never written down. + */ +const FORM_A = /(?:\bre-)?measured\s+(?:on|against)\s+this\s+tree/gi; + +/** + * FORM B — sites FORM A cannot reach, declared per file with the reason. + * + * ⭐ That this list is non-empty is the finding: a phrase probe is not a + * population. The card's own named instance carries no phrase at all — it is a + * bare corpus size in the present tense — so a sweep defined by the phrase + * would have reported the tree clean at the one site everybody agreed was + * broken. + */ +const FORM_B = [ + { + file: 'scripts/docs-audit/README.md', + // The hand-written docs corpus size. `pnpm check:docs-audit-scope` prints + // today's; every literal of the older figure left in this file is a claim + // about the corpus that the gate can contradict. + pattern: /\b178\b/g, + // `#11178` is a card number, not a corpus size. + reject: /#\d*178\b/, + why: 'the hand-written docs corpus size, which `check:docs-audit-scope` prints live', + }, +]; + +/** Excluded files, with the reason carried as data so every run prints it. */ +const EXCLUDED = [ + { + file: 'scripts/pm/dispatch-gates.mjs', + why: 'edited by open PRs #19162 and #19024, and its `--self-test` exceeds the agent ' + + 'container foreground cap (#17765), so a change here could not be verified from a seat', + }, +]; + +/* ─────────────────────────── the tests ─────────────────────────────────── */ + +/** + * T0 — is there a magnitude at all? Applied LAST, as the weakest escape hatch: + * a sentence that is already anchored or already loud is settled before the + * question is asked, so T0 only ever speaks about the residue. + * + * Nothing decays if nothing is counted. A ZERO is a magnitude — kind 2 is + * defined on one — so the word counts even where no digit does. Revisions, dates, card numbers, tool + * versions and exit codes are struck first: each is an identifier that happens + * to be spelled in digits, and counting them as magnitudes would classify every + * anchored citation as the thing an anchor exists to prevent. + */ +const strikeNonMagnitudes = (text) => text + .replace(/`[0-9a-f]{7,40}`/g, ' ') + .replace(/\b20\d{2}-\d{2}-\d{2}\b/g, ' ') + .replace(/#\d+/g, ' ') + .replace(/\bv?\d+\.\d+(?:\.\d+)?\b/g, ' ') + .replace(/\bexit \d+\b/gi, ' ') + .replace(/\bADR-\d+\b/g, ' ') + .replace(/\bbash \d\.\d\b/gi, ' '); + +const MAGNITUDE = /(? { + const flat = text.replace(/\s+/g, ' '); + for (const [re, name] of cues) if (re.test(flat)) return name; + return null; +}; + +/* ───────────────────── population derivation ───────────────────────────── */ + +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.turbo']); + +function* walk(dir) { + for (const entry of readdirSync(dir).sort()) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) yield* walk(full); + else if (st.isFile()) yield full; + } +} + +/** + * ## The window a verdict is read over, and why it is a SENTENCE + * + * The first build of this tool read a fixed ±6-line block. It cleared the one + * site everybody had already agreed was broken (`scripts/docs-audit/README.md`, + * the present-tense corpus size): six lines above it an UNRELATED sentence + * cites a revision, and a block window handed that anchor to a claim that has + * none. ⛔ An anchor belongs to the sentence that carries the figure and to no + * other, so T2 and T3 read the SENTENCE. + * + * T1 and T3 are the exceptions, and deliberately. A narrative frame is + * routinely set one sentence earlier — the dispatcher-vocabulary gate opens + * with "An earlier revision of this paragraph counted DIRECTORIES …" and only + * then reports the reading — so T1 also sees the sentence before. A named failure direction is routinely set one + * sentence LATER — the card's own kind-2 exemplar spells the zero in one + * sentence and names what would falsify it in the next — so T3 sees both + * neighbours. ⛔ T2 sees neither: an anchor belongs to its own sentence, and + * widening T2 is precisely the bug this window replaced. + * + * ⚠️ Neither test sees further than one sentence, and a frame or a direction + * set further back than that lands in the review residue — the safe direction, + * where a person judges it and records the judgement in `TRIAGE`. + */ +const SENTENCE_BREAK = /(?<=[.!?])[ \n]+(?=[A-Z⛔⭐⚠️`*#·•])/; + +/** Comment furniture, so a sentence reads as prose rather than as a comment. */ +function deComment(line, isMarkdown) { + let out = line.replace(/^\s*(?:\/\/+|\*)\s?/, ''); + if (!isMarkdown) out = out.replace(/^\s*#+\s?/, ''); + return out; +} + +/** + * Split a file into sentences, each carrying the 1-based line it starts on. + * The line map is built from the ORIGINAL line count, so a reported line is a + * line in the file a reader will open and not an offset into a derived string. + */ +function sentencesOf(text, isMarkdown) { + const lines = text.split('\n'); + const prose = lines.map((l) => deComment(l, isMarkdown)); + const out = []; + let buffer = []; + let bufferStart = 1; + const flush = () => { + if (!buffer.length) return; + const joined = buffer.join('\n'); + let offset = 0; + for (const piece of joined.split(SENTENCE_BREAK)) { + const before = joined.slice(0, offset); + out.push({ text: piece, line: bufferStart + (before.match(/\n/g) ?? []).length }); + offset += piece.length + 1; + } + buffer = []; + }; + prose.forEach((line, i) => { + if (line.trim() === '') { + // ⛔ A blank comment line does NOT end a sentence that was introducing a + // data block. A docblock here routinely ends its lead-in on a colon and + // then sets the table off with an empty ` *` line; flushing at that blank + // strands every figure in a fragment carrying no cue, and the sweep reads + // a docblock full of magnitudes as carrying none. + if (buffer.length && /:\s*$/.test(buffer[buffer.length - 1])) return; + flush(); + return; + } + if (!buffer.length) bufferStart = i + 1; + buffer.push(line); + }); + flush(); + return out; +} + +function hitsIn(relPath, text) { + const isMarkdown = relPath.endsWith('.md'); + const sentences = sentencesOf(text, isMarkdown); + const found = []; + sentences.forEach((s, i) => { + const lead = i > 0 ? sentences[i - 1].text : ''; + const next = i + 1 < sentences.length ? sentences[i + 1].text : ''; + FORM_A.lastIndex = 0; + if (FORM_A.test(s.text)) { + found.push({ file: relPath, line: s.line, form: 'A', sentence: s.text, lead, next }); + } + for (const form of FORM_B) { + if (form.file !== relPath) continue; + form.pattern.lastIndex = 0; + if (!form.pattern.test(s.text)) continue; + if (form.reject && form.reject.test(s.text)) continue; + found.push({ file: relPath, line: s.line, form: 'B', sentence: s.text, lead, next, why: form.why }); + } + }); + return found; +} + +export function collectPopulation() { + const excluded = new Set(EXCLUDED.map((e) => e.file)); + const hits = []; + for (const full of walk(SCAN_ROOT)) { + const rel = relative(REPO_ROOT, full).split(sep).join('/'); + if (excluded.has(rel) || rel === SELF) continue; + let text; + try { text = readFileSync(full, 'utf8'); } catch { continue; } + hits.push(...hitsIn(rel, text)); + } + return hits; +} + +/** + * The classifier proper: T1–T3 then T0, over one sentence (plus the sentence + * before it, for T1 only). T4 — level or relation — is a judgement and lives in + * `TRIAGE`. + */ +export function classify(sentence, lead = '', next = '') { + const frame = firstCue(FRAME_CUES, `${lead}\n${sentence}`); + if (frame) return { kind: 'KIND-1', cue: frame }; + const anchor = firstCue(ANCHOR_CUES, sentence); + if (anchor) return { kind: 'KIND-1', cue: anchor }; + const loud = firstCue(LOUD_CUES, `${lead}\n${sentence}\n${next}`); + if (loud) return { kind: 'KIND-2', cue: loud }; + if (!MAGNITUDE.test(strikeNonMagnitudes(sentence.replace(/\s+/g, ' ')))) { + return { kind: 'NO-FIGURE', cue: 'no magnitude survives T0' }; + } + return { kind: 'REVIEW', cue: 'present-tense magnitude, no frame, no anchor, nothing loud' }; +} + +/* ─────────────────────────── T4, recorded ───────────────────────────────── */ + +/** + * T4 — LEVEL or RELATION, the half no regex decides. + * + * With T1–T3 all declining, one question is left: does a reader act on the + * magnitude's **level**, or on a **relation** (a ratio, an ordering, an + * existence claim, a set equality) that the level's drift survives? A level is + * kind 3. A relation is an ILLUSTRATION — the figure sizes an argument that + * stays correct when the figure moves, and rewriting it buys nothing while + * costing a reviewer a diff. + * + * ⛔ Every row is keyed by an EXCERPT and never by a line number. The card that + * asked for this cited its own known instance by line, and the line had moved + * before anyone read the card — a ledger keyed on line numbers decays exactly + * the way its subject does. + * + * `verdict`: + * ILLUSTRATION — judged a relation; stays as written. + * REPAIRED — judged kind 3 and repaired in #17797. The self-test holds + * the excerpt ABSENT: if it comes back, so has the defect. + */ +const TRIAGE = [ + { file: 'scripts/check-bash32-floor.mjs', match: '8 findings, every one of them a false positive', + verdict: 'ILLUSTRATION', why: 'a counterfactual reading of the rule this gate replaced — the SIGN (all false positives) carries it, and the rule it measured is gone from this tree' }, + { file: 'scripts/check-cross-package-test-inputs.mjs', match: '6 exist only inside comments', + verdict: 'ILLUSTRATION', why: 'the load-bearing claim is the zero that follows it — none of the six escapes its package — and the corpus sizes only scale it' }, + { file: 'scripts/check-cross-package-test-inputs.mjs', match: '15 extensionless', + verdict: 'ILLUSTRATION', why: 'three spellings EXIST, which is why three branches exist; no branch is chosen by how many files carry it' }, + { file: 'scripts/check-cross-package-test-inputs.mjs', match: 'test files importing a workspace sibling by BARE specifier', + verdict: 'ILLUSTRATION', why: 'the ORDERING decides the design (bare specifiers dwarf relative ones), and no plausible drift reverses it' }, + { file: 'scripts/check-cross-package-test-inputs.mjs', match: '6 of the 60 declared globs are held by exactly such reads', + verdict: 'ILLUSTRATION', why: 'a non-emptiness claim — the blind spot is real — with a named specimen; nobody acts on how many' }, + { file: 'scripts/check-dispatcher-error-vocabulary.mjs', match: 'the naive `[{,]` anchor alone reports 19', + verdict: 'ILLUSTRATION', why: 'two recognisers compared at one moment; 19-against-13 is a relation, and the six extras are named individually' }, + { file: 'scripts/check-driver-conformance.mjs', match: "105 files under `driver-sql/src` carry a literal", + verdict: 'ILLUSTRATION', why: 'the claim is that those files are NOT defects — a class judgement; the count only says the class is large' }, + { file: 'scripts/check-examples-live-imports.mjs', match: '76 coupled files across 3 packages', + verdict: 'ILLUSTRATION', why: 'the refusal rests on the SHARE (a tiny coupled set inside a huge root), which no ordinary churn moves' }, + { file: 'scripts/check-logger-receiver-detach.mjs', match: 'over the property reads whose NAME is a log channel', + verdict: 'ILLUSTRATION', why: 'the section head one paragraph up names `--census` as reproducing every number in it — instrument-backed, one sentence further than T3 reaches' }, + { file: 'scripts/check-parse-guard.mjs', match: '9 of the 28 sites live in', + verdict: 'ILLUSTRATION', why: 'the finding is that the printed remedy is false for a subset that EXISTS; the subset is then described by kind, not by size' }, + { file: 'scripts/check-pnpm-filter-targets.mjs', match: 'Measured on this tree, the declaration names 235 tracked files', + verdict: 'REPAIRED', why: 'kind 3 — a declaration-honesty ratio over a root that grows with every script added, read as current state, reprinted by nothing' }, + { file: 'scripts/check-published-files.mjs', match: 'Measured on this tree: the declaration names 5263 tracked files', + verdict: 'REPAIRED', why: 'kind 3 — same shape over the whole publishable workspace, which moves on every package add or removal' }, + { file: 'scripts/check-release-section-coverage.mjs', match: 'assertion 1 fires on 24 minors', + verdict: 'ILLUSTRATION', why: 'the figures justify shipping WITH a baseline, and the ruling that fixes them is dated in the sentence before' }, + { file: 'scripts/check-sdui-lockstep.mjs', match: 'measured on this tree at 96 hint literals', + verdict: 'ILLUSTRATION', why: '"at 96 hint literals" is the CONDITION of the reading, not its subject: the claim is about which leading-dot literals the allowlist admits' }, + { file: 'scripts/check-skill-compatibility-version.mjs', match: 'both were measured on this tree', + verdict: 'REPAIRED', why: 'kind 3 — four precision ratios deciding which roots are declared, all moving with the tree and none reprinted' }, + { file: 'scripts/check-slot-lookup-ratchet.mjs', match: 'Measured on this tree: 46s', + verdict: 'REPAIRED', why: 'kind 3, and the worst-ageing kind: a duration is a reading of one BOX as much as of one tree, so it decays without the tree changing at all' }, + { file: 'scripts/check-test-source-alias.mjs', match: '1832 of the 4844 tracked files', + verdict: 'ILLUSTRATION', why: 'it records the state the fix REMOVED — the derivation no longer decides this gate by where a package sits — in a past-tense narrative T1 does not reach' }, + { file: 'scripts/check-where-matcher-conformance.mjs', match: '2889 of the 2889 files', + verdict: 'ILLUSTRATION', why: 'a set EQUALITY held in both directions by this gate own liveness-and-precision pin: it reds on drift rather than decaying' }, + { file: 'scripts/check-widget-option-census.mjs', match: 'inheritable literals: 0', + verdict: 'ILLUSTRATION', why: 'a table of zeros making an inheritance claim; the zeros are the claim' }, + { file: 'scripts/docs-audit/README.md', match: 'matched 82, 113, 43, 13 and 10 of 178 pages', + verdict: 'ILLUSTRATION', why: 'past-tense narrative of the first build guards — the words arrived as real declarations and matched; it describes a tree that is gone' }, + { file: 'scripts/docs-audit/README.md', match: 'named by 59 of 178 pages', + verdict: 'ILLUSTRATION', why: 'the argument is the 15% corpus SHARE rule stated two lines up; this is the share that condemned one term when the rule was written' }, + { file: 'scripts/docs-audit/README.md', match: 'reads the same 178-page corpus', + verdict: 'REPAIRED', why: 'kind 3, and the instance the card names: a present-tense corpus size that `check:docs-audit-scope` contradicts today' }, + { file: 'scripts/eslint-stack-headroom.mjs', match: 'the narrow scope is the worst case', + verdict: 'ILLUSTRATION', why: 'worst-case against intermittent — a comparison between two arms, and the gate is built on the comparison, not on either rate' }, + { file: 'scripts/git-merge-regen.mjs', match: 'measured on this tree: 18 rows', + verdict: 'ILLUSTRATION', why: 'the claim is that reverting #13585 loosening would turn the reconciliation RED; the row split only shows both branches are populated' }, + { file: 'scripts/pm/bare-root-worklist.mjs', match: 'reaches 2755 of ', + verdict: 'ILLUSTRATION', why: 'a set equality this file own --self-test re-derives and reds on, which is the whole point of a worklist that runs' }, + { file: 'scripts/pm/bare-root-worklist.mjs', match: 'numerator from the instrument own printed scan ', + verdict: 'ILLUSTRATION', why: 'the sentence names both instruments the two terms came from and refuses to carry either from the row above' }, + { file: 'scripts/pm/bare-root-worklist.mjs', match: 'the ratio does not change the verdict either direction', + verdict: 'ILLUSTRATION', why: 'the sentence says in its own words that the level is not acted on — a relation by declaration' }, +]; + +/* ──────────────────────────── the controls ─────────────────────────────── */ + +/** + * ⭐ BOTH DIRECTIONS, and neither one alone. A classifier only ever seen to + * fire proves nothing; one only ever seen to stay quiet proves less. + * + * Each control carries a `liveness` substring that must still be present in the + * file it was taken from. ⛔ A control quoting a sentence the tree no longer + * holds is a control that has stopped controlling anything, and it would pass + * in silence — the exact failure this whole file is about. + */ +const CONTROLS = [ + { + name: 'kind 1 — a citation carrying its revision, left alone', + file: 'scripts/check-tier-file-adoption.mjs', + liveness: 'Measured on this tree at d03c3c96d6', + sentence: 'Measured on this tree at d03c3c96d6, on the ONE package that owns tier files: ' + + '`packages/cli/vitest.config.ts` names `OS_TEST_TIERS` 5 times and `packages/cli/vitest-tiers.ts` 4 times.', + lead: '', next: '', + expect: 'KIND-1', + }, + { + name: 'kind 2 — a zero whose failure direction is named, left alone', + file: 'scripts/docs-audit/affected-docs.mjs', + liveness: 'zero commands declare `static topic`', + sentence: 'Measured on this tree: zero commands declare `static topic`, and the one `static id` ' + + "(`init.ts` -> `'init'`) agrees with its path, so nothing is mis-derived today.", + lead: '', + next: 'The failure direction if that ever changes is a phrase matching NO page — a recall miss the ' + + 'anchor list makes visible, not a false positive that pollutes the work list.', + expect: 'KIND-2', + }, + { + name: 'kind 3 — the card own named instance, CAUGHT (pre-repair text)', + file: 'scripts/docs-audit/README.md', + // ⛔ No liveness key: this is the sentence as it read BEFORE the repair, so + // the tree is required NOT to hold it. `absent` is checked instead. + absent: 'reads the same 178-page corpus', + sentence: '**Cost** (the card open question): the anchor derivation reads the same 178-page corpus ' + + 'the old one did, plus the 18 route-source/ledger files (~875 KB) and one `git show` per changed file per side.', + lead: 'How often it renders, re-derived over the 40 first-parent commits ending at `e43b18fd9`: ' + + '3 of the 17 package-touching runs (18%).', + next: 'Measured end-to-end on the ten PRs above, `node affected-docs.mjs` went from 85-195 ms to 114-582 ms.', + expect: 'REVIEW', + }, + { + name: 'kind 3 repaired — the SAME site now points at the gate that prints the value', + file: 'scripts/docs-audit/README.md', + liveness: 'check:docs-audit-scope', + sentence: '**Cost** (the card open question): the anchor derivation reads the same hand-written corpus ' + + 'the old one did — run `check-audit-scope.mjs` for today\'s page count rather than trusting one written ' + + 'down here — plus the 18 route-source/ledger files and one `git show` per changed file per side.', + lead: '', next: '', + expect: 'KIND-2', + }, + { + name: 'kind 2 — a refusal a live assertion holds, left alone', + file: 'scripts/check-skill-compatibility-version.mjs', + liveness: 'the refusal is pinned in the self-test', + sentence: 'So the manifest side stays undeclared, deliberately, and the refusal is pinned in the ' + + 'self-test rather than left in this paragraph — a later author who adds `packages/**` meets an assertion.', + lead: '', next: '', + expect: 'KIND-2', + }, + { + name: '⛔ the one-sentence anchor rule — a neighbour revision does NOT clear a bare magnitude', + file: 'scripts/pm/measurement-claim-triage.mjs', + sentence: 'The declaration names 235 tracked files under scripts/ and this gate reads 228 of them.', + lead: 'Re-derived over the 40 first-parent commits ending at `e43b18fd9`.', + next: '', + expect: 'REVIEW', + }, +]; + +/* ──────────────────────────────── report ───────────────────────────────── */ + +const KINDS = ['KIND-1', 'KIND-2', 'NO-FIGURE', 'REVIEW']; + +export function triageOf(hit) { + const flat = hit.sentence.replace(/\s+/g, ' '); + return TRIAGE.find((row) => row.file === hit.file && row.verdict !== 'REPAIRED' && flat.includes(row.match)) + ?? null; +} + +function verdicts() { + return collectPopulation().map((hit) => ({ hit, ...classify(hit.sentence, hit.lead, hit.next) })); +} + +export function report({ reviewOnly = false } = {}) { + const rows = verdicts(); + const counts = Object.fromEntries(KINDS.map((k) => [k, rows.filter((r) => r.kind === k).length])); + + console.log( + `measurement-claim triage: ${rows.length} claim(s) over ` + + `${new Set(rows.map((r) => r.hit.file)).size} file(s) under scripts/ — ` + + KINDS.map((k) => `${k} ${counts[k]}`).join(' · '), + ); + console.log( + ' ⛔ REVIEW is a CANDIDATE bucket, not a defect count — T4 (level or relation) is a judgement,\n' + + ' recorded per site in TRIAGE. ⛔ Reading any number above as a finding count is the error\n' + + ' this tool exists to prevent.', + ); + for (const e of EXCLUDED) console.log(` ⚠️ NOT SWEPT ${e.file} — ${e.why}`); + console.log(''); + + for (const row of rows) { + if (reviewOnly && row.kind !== 'REVIEW') continue; + const t = row.kind === 'REVIEW' ? triageOf(row.hit) : null; + const tag = row.kind === 'REVIEW' ? (t ? `T4 ${t.verdict}` : 'T4 UNTRIAGED') : row.kind; + console.log(` ${tag.padEnd(18)} ${row.hit.file}:${row.hit.line} [${row.cue}]`); + if (t) console.log(` ${' '.repeat(18)} ↳ ${t.why}`); + } + return rows; +} + +/* ─────────────────────────────── self-test ─────────────────────────────── */ + +const SELF_TEST_VERDICT = 'measurement-claim-triage self-test: controls hold in BOTH directions'; + +export function selfTest() { + const problems = []; + const rows = verdicts(); + + // #4690: an empty population refuses rather than printing a confident zero. + if (rows.length === 0) problems.push('PREREQUISITE NOT MET: the sweep found no claims at all under scripts/.'); + + for (const control of CONTROLS) { + const got = classify(control.sentence, control.lead, control.next).kind; + if (got !== control.expect) problems.push(`CONTROL "${control.name}": expected ${control.expect}, got ${got}.`); + let text = ''; + try { text = readFileSync(join(REPO_ROOT, control.file), 'utf8'); } catch { + problems.push(`CONTROL "${control.name}": ${control.file} is gone, so the control quotes nothing.`); + continue; + } + if (control.liveness && !text.includes(control.liveness)) { + problems.push( + `CONTROL "${control.name}": ${control.file} no longer carries \`${control.liveness}\`. ` + + 'A control quoting a sentence the tree has lost passes in silence — re-point it at a live one.', + ); + } + if (control.absent && text.includes(control.absent)) { + problems.push( + `CONTROL "${control.name}": ${control.file} carries \`${control.absent}\` again. ` + + 'That is the repaired kind-3 sentence restored — the defect is back.', + ); + } + } + + // Every REVIEW hit is judged by somebody. + for (const row of rows) { + if (row.kind !== 'REVIEW') continue; + if (!triageOf(row.hit)) { + problems.push( + `UNTRIAGED ${row.hit.file}:${row.hit.line} — a present-tense magnitude with no frame, no anchor ` + + 'and nothing loud, and no TRIAGE row judging it. Judge it (level or relation) and record why.', + ); + } + } + + // A TRIAGE row that matches nothing is a judgement about a tree that is gone. + for (const row of TRIAGE) { + const live = rows.some((r) => r.hit.file === row.file && r.hit.sentence.replace(/\s+/g, ' ').includes(row.match)); + if (row.verdict === 'ILLUSTRATION' && !live) { + problems.push(`STALE TRIAGE row ${row.file} "${row.match}" matches nothing in the population any more.`); + } + if (row.verdict === 'REPAIRED' && live) { + problems.push(`REGRESSED ${row.file} "${row.match}" is back in the population — the repair was undone.`); + } + } + + // ⛔ Every cue must still match something, or it is decoration. + for (const [cues, label] of [[FRAME_CUES, 'FRAME'], [ANCHOR_CUES, 'ANCHOR'], [LOUD_CUES, 'LOUD']]) { + for (const [re, name] of cues) { + const corpus = rows.map((r) => `${r.hit.lead}\n${r.hit.sentence}\n${r.hit.next}`.replace(/\s+/g, ' ')); + const controls = CONTROLS.map((c) => `${c.lead}\n${c.sentence}\n${c.next}`.replace(/\s+/g, ' ')); + if (![...corpus, ...controls].some((t) => re.test(t))) { + problems.push(`DEAD CUE ${label}/${name} ${re} matches nothing in the population or the controls.`); + } + } + } + + // This file's own prose must make no claim the sweep would have to judge. + const own = readFileSync(join(REPO_ROOT, SELF), 'utf8'); + const prose = own.slice(0, own.indexOf('const TRIAGE')); + FORM_A.lastIndex = 0; + if (FORM_A.test(prose)) { + problems.push('This file own prose now carries the swept phrase outside CONTROLS, and it excludes itself from the sweep.'); + } + + if (problems.length) { + console.error(`\n✗ measurement-claim-triage self-test: ${problems.length} problem(s)\n`); + for (const p of problems) console.error(` - ${p}`); + console.error(''); + process.exit(1); + } + console.log(`✓ ${SELF_TEST_VERDICT} (${CONTROLS.length} controls, ${TRIAGE.length} recorded judgements).`); + return SELF_TEST_VERDICT; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + if (selfTest() !== SELF_TEST_VERDICT) { + console.error('\n✗ measurement-claim-triage: selfTest() returned without reaching its verdict.\n'); + process.exit(1); + } + } else report({ reviewOnly: process.argv.includes('--review') }); +}