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/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/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/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(); +}