diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bb0c37ed2f..120bda8127 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2179,6 +2179,40 @@ jobs: - name: Check no new same-name dual-source spec exports run: pnpm --filter @objectstack/spec run check:dual-source-exports + # [#10199] The mechanized form of the 2026-08-20 ruling on #10096: + # 「浏览器可达的 spec 导出面必须 schema-free」. Walks the built module graph + # behind every subpath the exports map publishes and fails if one DECLARED + # browser-reachable (browser-reachable-entries.json — today `./meta-spelling`) + # links zod, directly or through a relative hop, or links an external it has + # not declared. + # + # WHY IT NEEDS A GATE AT ALL. The principle bound as prose until now, and + # the failure it guards was invisible to every check this repo had: one + # string fold reached through `/shared` cost +246.9 KB minified / +69.7 KB + # gzipped, one pure predicate through `/security` cost +261.5 KB, and + # objectui#5324 records the CONSUMER's own bundle-budget check reporting + # PASS on the PR that added the bytes. A budget cannot attribute a delta + # that arrives inside a dependency it already imports; a module graph can. + # + # WHY THIS JOB AND THIS POSITION. It reads the BUILT `.mjs`/`.js` bundles a + # consumer's import resolves to — not the source, and not the `.d.ts` — so + # it belongs after the build step with the other dist-reading consumer + # gates (`check:exported-any`, `check:dual-source-exports` above), not with + # the tsx-only artifact gates near the top. On an unbuilt or stale tree it + # is a HARD REFUSAL naming the build command, never a skip: a + # build-dependent gate that reads nothing reports NOT MEASURED as if it + # were measured and clean (#4690). + # + # It adds no required context — a step in an existing job, so no open PR + # waits on a check whose name no head has ever reported (#9325). Reads 34 + # bundles in ~1.5s. Self-tests first, like its two neighbours, and that + # self-test is load-bearing here for a specific reason: this tree has ZERO + # relative hops between bundles (every entry is self-contained), so the + # transitive walk that keeps the gate honest the day `splitting` is turned + # on can only be exercised against fixtures. + - name: Check declared browser-reachable spec entries link no zod + run: pnpm --filter @objectstack/spec run check:browser-reachable-entries + # Anti-drift for the skill EXAMPLES, not just the skill reference indexes # (#3094). The TypeScript in skills/ is the first thing an AI copies when # authoring metadata, yet nothing type-checked it — so it rotted silently diff --git a/packages/spec/browser-reachable-entries.json b/packages/spec/browser-reachable-entries.json new file mode 100644 index 0000000000..d4ef106661 --- /dev/null +++ b/packages/spec/browser-reachable-entries.json @@ -0,0 +1,33 @@ +{ + "_comment": "The DECLARED browser-reachable export surface of @objectstack/spec (#10199), and the contract half of check:browser-reachable-entries. The 2026-08-20 maintainer ruling on #10096 minted the standing principle 「浏览器可达的 spec 导出面必须 schema-free」: an export surface browser/client consumers reach carries vocabulary — maps, folds, enums, pure predicates — without linking the zod schema/validation machinery. This file says WHICH entries make that promise; the gate reads the BUILT bundle each one resolves to and proves the promise. Every subpath in package.json's exports map must appear in exactly one of the three sections below — an entry nobody classified fails the gate's reconciliation rather than quietly dropping out of coverage (the check:generated ledger pattern). Hand-maintained on purpose, like dual-source-exports.baseline.json next door: promoting an entry to browserReachable is a CONTRACT decision a maintainer makes, not something a gen: command should be able to grant. The gate is additive/shrink-only — an unlisted entry is unjudged, so no weakening question arises.", + "browserReachable": { + "./meta-spelling": { + "why": "The reference pattern the ruling shipped (#10096): the /meta URL-spelling contract as vocabulary — one frozen map, one fold, two refusal predicates — with the map derived at BUILD time by gen:meta-url-spelling so no schema graph is linked. Reaching the same four symbols through ./shared cost a browser consumer +246.9 KB minified / +69.7 KB gzipped.", + "externals": [] + } + }, + "_unjudgedComment": "Entries this gate asserts NOTHING about. They are not declared browser-reachable, so linking zod is not a defect here — these are the server/build-time surfaces. Listing them is what makes the reconciliation two-directional: a new exports-map subpath cannot land without someone deciding which list it belongs in. Moving one UP to browserReachable is the only direction this file is meant to travel, and it is a maintainer decision plus a passing gate, never a ledger edit alone.", + "unjudged": [ + ".", + "./ai", + "./api", + "./automation", + "./cloud", + "./contracts", + "./data", + "./identity", + "./integration", + "./kernel", + "./qa", + "./security", + "./shared", + "./studio", + "./system", + "./ui" + ], + "_notAModuleComment": "Exports-map subpaths that resolve to something other than a JS module, so there is no module graph to walk and no zod link to look for. A separate section from `unjudged` because the two say different things to a reader: `unjudged` means 'a module we chose not to judge', this means 'not a module at all'. The gate refuses to scan these rather than silently reporting them clean.", + "notAModule": [ + "./openapi.json", + "./package.json" + ] +} diff --git a/packages/spec/package.json b/packages/spec/package.json index 5f9cf4f854..2e183a4efc 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -216,6 +216,7 @@ "check:export-origins": "tsx scripts/build-export-origins.ts --self-test && tsx scripts/build-export-origins.ts --check", "check:exported-any": "tsx scripts/check-exported-any.ts --self-test && tsx scripts/check-exported-any.ts", "check:dual-source-exports": "tsx scripts/check-dual-source-exports.ts --self-test && tsx scripts/check-dual-source-exports.ts", + "check:browser-reachable-entries": "tsx scripts/check-browser-reachable-entries.ts --self-test && tsx scripts/check-browser-reachable-entries.ts", "check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check", "gen:migration-registry": "tsx scripts/build-migration-registry.ts", "check:migration-registry": "tsx scripts/build-migration-registry.ts --self-test --check", diff --git a/packages/spec/scripts/check-browser-reachable-entries.ts b/packages/spec/scripts/check-browser-reachable-entries.ts new file mode 100644 index 0000000000..683fa9fd82 --- /dev/null +++ b/packages/spec/scripts/check-browser-reachable-entries.ts @@ -0,0 +1,728 @@ +#!/usr/bin/env tsx +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Mechanize the schema-free-export principle (#10199). + * + * pnpm --filter @objectstack/spec check:browser-reachable-entries # self-test + audit + * tsx scripts/check-browser-reachable-entries.ts --self-test # fixture check only + * + * ## The principle, and why prose was not enough + * + * The 2026-08-20 maintainer ruling on #10096 minted a standing principle for + * this package's export design (recorded verbatim, untranslated): + * + * > 浏览器可达的 spec 导出面必须 schema-free。 + * + * — an export surface browser/client consumers reach carries vocabulary (maps, + * folds, enums, pure predicates) without linking the zod schema/validation + * machinery. #10096's landing wrote it into `README.md` and `src/index.ts` and + * shipped the reference pattern (`@objectstack/spec/meta-spelling`); the ruling + * named mechanizing it a welcome follow-up, which is this file. + * + * The failure shape it guards is measured, not theoretical: ONE string fold + * reached through `/shared` cost +246.9 KB minified / +69.7 KB gzipped (#10096), + * and one pure predicate reached through `/security` cost +261.5 KB minified + * (#10031). Both were invisible to every gate this repo had — objectui#5324 + * records that the consumer's OWN bundle-budget check reported PASS on the PR + * that added the bytes. A budget check cannot see this: the bytes arrive inside + * a dependency the consumer already imports, so the budget moves by a delta + * nobody attributes. What is decidable is the module graph, and that is what + * this reads. + * + * ## What it judges, and what it deliberately does not + * + * `browser-reachable-entries.json` is the contract half. Only the entries in its + * `browserReachable` map are judged; everything else is UNJUDGED, so this gate is + * additive and shrink-only and raises no weakening question. What it will not + * let you do is leave an entry unclassified: every subpath in the `exports` map + * must appear in exactly one of the ledger's three sections, in both directions + * — the `check:generated` reconciliation pattern, for the same reason (a surface + * nobody classified silently drops out of coverage). + * + * For a judged entry, two assertions: + * + * 1. **no zod anywhere in its built module graph** — `zod` or `zod/*`, reached + * directly or through any relative hop; + * 2. **every bare external it links is declared** in that entry's `externals` + * list. `./meta-spelling` declares `[]`, i.e. a closed graph. This is the + * half that survives indirection: a wrapper package that re-exports zod + * would satisfy (1) — the bundle names the wrapper, not zod — and it cannot + * satisfy (2) without someone adding a line to the ledger and explaining it. + * + * ## Instrument: a static scan of the BUILT bundle, not an esbuild probe + * + * #10199 offered two instruments and asked for the cheaper one unless it proved + * spoofable. It is the cheaper one, with the spoof paths closed rather than + * assumed away. Measured on this tree, each closure named with what it answers: + * + * - **"it scans the wrong file."** The file to scan is resolved THROUGH the + * `exports` map (`import`.default and `require`.default), not by the + * `dist//index.mjs` convention. Repointing a subpath at a + * schema-bearing bundle moves what this reads, exactly as it moves what a + * consumer loads. + * - **"it only reads one file."** Today every entry is self-contained + * (`splitting: false`, and the scan finds zero relative specifiers across all + * 34 bundles), so a one-file check would pass today and go quietly blind the + * day `splitting` is turned on and zod arrives through a shared chunk. So the + * walk is TRANSITIVE over relative hops, and a relative specifier that does + * not resolve is a hard error — an incomplete walk reporting "no zod" is the + * false green this gate exists to prevent. The self-test drives that walk + * over a synthetic chunk chain, because this tree cannot exercise it. + * - **"it reads prose."** The bundles carry documentation strings, and one of + * them really does contain the text `from '@objectstack/spec/ui'` inside a + * `reason:` string in `dist/index.mjs` — a naive grep reports it as an + * import. Specifiers are therefore accepted only where the statement keyword + * sits in CODE, decided by `scripts/js-comment-mask.mjs`, this tree's one + * answer to "comment, literal, or code". + * - **"zod stops being an external and gets inlined."** This is the one spoof a + * specifier scan genuinely cannot see, and it is why the gate carries a + * CALIBRATION assertion instead of a fragile inlined-runtime regex: across + * all scanned entries the scan must find at least one real zod link. Today it + * finds 15. If a bundler change inlines zod, or esbuild's emitted import + * shape moves out from under the scanner, that count goes to zero and the + * gate REFUSES — loudly, naming the instrument — instead of reporting every + * entry clean. A zod-version-specific content marker would have to be + * rewritten on every zod major; this cannot rot, because it is calibrated + * against whatever the tree actually builds. + * + * The esbuild probe (instrument 2) buys one thing over this: it measures the + * exports map's resolution rather than the build layout. Resolving through the + * exports map buys the same thing for no dependency and no bundle step, so it is + * not adopted. If a future entry ever ships as something a static scan cannot + * follow — a wildcard subpath, a conditional export tree — that is the point to + * revisit it, and the reconciliation above is what will force the conversation. + * + * ## It reads BUILT output, so an unbuilt tree is NOT MEASURED + * + * Never "not applicable": a missing `dist` would make "found no zod link" and + * "read no bundle" the same green, and a stale one would answer about a build + * that predates the import under test (#4690's class). Both are refusals, via + * `lib/dist-freshness.ts` — on the BUNDLE axis, not the `.d.ts` axis its sibling + * uses; that file's docblock has the measurement for why the two differ. + * + * Exit: 0 = every declared entry is schema-free and the ledger reconciles; + * 1 = a violation, an unclassified entry, an unbuilt/stale tree, or an + * instrument that can no longer detect zod. + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { scanSource } from '../../../scripts/js-comment-mask.mjs'; +import { inspectBundleFreshness } from './lib/dist-freshness'; + +const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const LEDGER_PATH = join(PKG_DIR, 'browser-reachable-entries.json'); +const RERUN = 'pnpm --filter @objectstack/spec check:browser-reachable-entries'; +const SELF_TEST = process.argv.includes('--self-test'); + +/** A specifier naming zod itself, or any of its subpaths. */ +function isZodSpecifier(spec: string): boolean { + return spec === 'zod' || spec.startsWith('zod/'); +} + +function isRelative(spec: string): boolean { + return spec.startsWith('./') || spec.startsWith('../'); +} + +// --------------------------------------------------------------------------- +// The scanner: which module specifiers does this bundle actually link? +// --------------------------------------------------------------------------- + +/** + * The three statement keywords a module specifier can sit behind, in output + * esbuild emits: `from` (static import and re-export), `import` (bare + * side-effect import, and the dynamic call form) and `require` (the CJS + * bundle). Published here rather than left inside the matcher because a source + * scan only ever sees the spellings it knows, and an unrecognised one produces + * no finding — silently. The calibration assertion is the other half of that + * bargain: if this list ever stops matching what the bundler emits, the zod + * count falls to zero and the run refuses. + */ +const SPECIFIER_KEYWORDS = new Set(['from', 'import', 'require']); + +/** + * Read backwards from a string literal's opening quote and decide whether this + * literal is a MODULE SPECIFIER or just a string. + * + * Walked character by character rather than matched with a regex over a fixed + * window, because the two mistakes a window makes are both real here: + * `Array.from('abc')` and `Buffer.from('...')` put the keyword `from` + * immediately before a string literal, and only the preceding `.` tells them + * apart from a re-export; and a window that happens to start exactly at the + * keyword lets a `^` anchor stand in for the boundary check. + * + * Comment characters are treated as whitespace — `import /* c *\/ 'x'` is a + * legal spelling — using the same flags that decided the literal was a literal. + */ +function specifierKeywordBefore( + source: string, + comment: Uint8Array, + quoteIndex: number, +): string | null { + let i = quoteIndex - 1; + const skipBlanks = (): void => { + while (i >= 0 && (comment[i] === 1 || /\s/.test(source[i]!))) i--; + }; + + skipBlanks(); + // The call forms — `require(`, `import(` — carry an open paren; `from` never does. + if (i >= 0 && source[i] === '(') { + i--; + skipBlanks(); + } + + let end = i; + while (i >= 0 && /[\w$]/.test(source[i]!)) i--; + const word = source.slice(i + 1, end + 1); + if (!SPECIFIER_KEYWORDS.has(word)) return null; + + // A member access is never a module specifier: `Array.from('abc')` is not an + // import, and neither is `mod.require('x')`. + if (i >= 0 && source[i] === '.') return null; + + return word; +} + +export interface Specifier { + spec: string; + keyword: string; +} + +/** + * Every module specifier this bundle links, in file order. + * + * Exported so the self-test drives the real scanner over fixture sources rather + * than over this tree, which would only prove what today's build happens to + * contain (#4690: a scan whose green result is "nothing found" has to prove it + * can still find something). + */ +export function specifiersOf(source: string): Specifier[] { + const { comment, literal } = scanSource(source); + const found: Specifier[] = []; + let i = 0; + + while (i < source.length) { + if (literal[i] !== 1 || comment[i] === 1) { + i++; + continue; + } + const start = i; + while (i < source.length && literal[i] === 1 && comment[i] !== 1) i++; + + // The delimiters are CODE either side of a literal's content, so `start - 1` + // is the opening quote whenever this run really is a string. + const quoteIndex = start - 1; + if (quoteIndex < 0) continue; + const quote = source[quoteIndex]; + if (quote !== "'" && quote !== '"' && quote !== '`') continue; + + const keyword = specifierKeywordBefore(source, comment, quoteIndex); + if (keyword) found.push({ spec: source.slice(start, i), keyword }); + } + + return found; +} + +// --------------------------------------------------------------------------- +// The walk: one entry's whole built module graph +// --------------------------------------------------------------------------- + +export interface BundleGraph { + /** Every file visited, repo-relative, in visit order. */ + files: string[]; + /** Bare specifier → the files that link it. */ + externals: Map; + /** Relative specifiers that did not resolve — always an error, never a skip. */ + unresolved: { from: string; spec: string }[]; +} + +/** + * Follow a relative specifier the way a runtime would, over what a bundler + * actually emits. + * + * esbuild writes explicit extensions, so the bare join is the case that fires + * today; the extension and directory-index fallbacks exist so that a future + * output shape degrades into a RESOLVED hop rather than into an `unresolved` + * error nobody expected. A specifier that matches none of them is reported, not + * dropped. + */ +function resolveRelative(fromFile: string, spec: string): string | null { + const base = join(dirname(fromFile), spec); + const candidates = [ + base, + `${base}.mjs`, + `${base}.js`, + join(base, 'index.mjs'), + join(base, 'index.js'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate) && !candidate.endsWith('/')) { + try { + readFileSync(candidate); + return candidate; + } catch { + // A directory matched `existsSync`; keep looking at the index forms. + } + } + } + return null; +} + +/** + * Walk one built entry file and everything it reaches through relative hops. + * + * Bare specifiers are recorded as external links and NOT followed: this gate + * judges what the published bundle links, and following a bare specifier would + * mean walking `node_modules`, which is a different (and much larger) question + * than the one #10199 asks. The `externals` allowlist is how indirection through + * a bare specifier is kept honest instead. + */ +export function walkBundle(root: string, entryFile: string): BundleGraph { + const graph: BundleGraph = { files: [], externals: new Map(), unresolved: [] }; + const seen = new Set(); + const queue = [entryFile]; + + while (queue.length) { + const file = queue.shift()!; + if (seen.has(file)) continue; + seen.add(file); + const rel = relative(root, file); + graph.files.push(rel); + + const source = readFileSync(file, 'utf8'); + for (const { spec } of specifiersOf(source)) { + if (isRelative(spec)) { + const target = resolveRelative(file, spec); + if (target) queue.push(target); + else graph.unresolved.push({ from: rel, spec }); + continue; + } + const linkers = graph.externals.get(spec) ?? []; + linkers.push(rel); + graph.externals.set(spec, linkers); + } + } + + return graph; +} + +// --------------------------------------------------------------------------- +// The ledger +// --------------------------------------------------------------------------- + +interface Ledger { + browserReachable: Record; + unjudged: string[]; + notAModule: string[]; +} + +type ExportsMap = Record; + +/** + * The files a consumer really loads for one subpath: the `import` condition's + * default and the `require` condition's default. + * + * Both are scanned. They are separate bundles built by separate tsup passes, and + * a promise that holds for the ESM half while the CJS half links zod is not a + * promise — a bundler picking the `require` condition is the ordinary case for + * a consumer on an older toolchain. + */ +function targetsOf(entry: unknown): string[] { + const out: string[] = []; + const visit = (node: unknown): void => { + if (typeof node === 'string') { + out.push(node); + return; + } + if (!node || typeof node !== 'object') return; + const record = node as Record; + for (const key of ['import', 'require']) { + const branch = record[key]; + if (!branch || typeof branch !== 'object') continue; + const target = (branch as Record).default; + if (typeof target === 'string') out.push(target); + } + }; + visit(entry); + return out; +} + +function reconcile(exportsMap: ExportsMap, ledger: Ledger, problems: string[]): void { + const declared = new Map(); + const claim = (subpath: string, section: string): void => { + const already = declared.get(subpath); + if (already) { + problems.push( + `'${subpath}' is classified twice — in '${already}' and in '${section}'. ` + + `Every subpath belongs to exactly one section.`, + ); + return; + } + declared.set(subpath, section); + }; + + for (const subpath of Object.keys(ledger.browserReachable)) claim(subpath, 'browserReachable'); + for (const subpath of ledger.unjudged) claim(subpath, 'unjudged'); + for (const subpath of ledger.notAModule) claim(subpath, 'notAModule'); + + for (const subpath of Object.keys(exportsMap)) { + if (declared.has(subpath)) continue; + problems.push( + `'${subpath}' is in package.json's exports map but is classified in NO section of\n` + + ` browser-reachable-entries.json. Decide what it is:\n` + + ` • browserReachable — a browser/client consumer reaches it, so it must link no zod;\n` + + ` • unjudged — a server/build-time surface this gate asserts nothing about;\n` + + ` • notAModule — it resolves to something other than a JS module.\n` + + ` An unclassified entry is how a surface silently drops out of coverage.`, + ); + } + + for (const [subpath, section] of declared) { + if (subpath in exportsMap) continue; + problems.push( + `'${subpath}' is listed in browser-reachable-entries.json ('${section}') but is NOT in\n` + + ` package.json's exports map. Delete the stale line — a ledger row for an entry\n` + + ` nobody publishes reads as coverage that does not exist.`, + ); + } +} + +// --------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------- + +function audit(): never { + const freshness = inspectBundleFreshness(PKG_DIR, 'check', RERUN); + if (!freshness.fresh) { + console.error(`❌ check:browser-reachable-entries — NOT MEASURED.${freshness.message}`); + process.exit(1); + } + + const pkg = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8')) as { + exports?: ExportsMap; + }; + const exportsMap = pkg.exports ?? {}; + const ledger = JSON.parse(readFileSync(LEDGER_PATH, 'utf8')) as Ledger; + + const problems: string[] = []; + reconcile(exportsMap, ledger, problems); + + // Every JS entry is scanned, judged or not: the unjudged ones are what + // calibrates the instrument (see the header). Scanning them costs ~0.4s. + const notAModule = new Set(ledger.notAModule); + let zodLinksFound = 0; + let bundlesScanned = 0; + const judged = ledger.browserReachable; + + for (const subpath of Object.keys(exportsMap)) { + if (notAModule.has(subpath)) continue; + const contract = judged[subpath]; + + for (const target of targetsOf(exportsMap[subpath])) { + const file = join(PKG_DIR, target); + if (!existsSync(file)) { + problems.push( + `'${subpath}' resolves to ${target}, which does not exist. The exports map points at a\n` + + ` file this build did not emit.`, + ); + continue; + } + + const graph = walkBundle(PKG_DIR, file); + bundlesScanned++; + + for (const { from, spec } of graph.unresolved) { + problems.push( + `'${subpath}': ${from} links '${spec}', which resolves to no file on disk. The walk of\n` + + ` this entry's module graph is INCOMPLETE, so no verdict about it can be trusted.`, + ); + } + + const zodLinkers = [...graph.externals.entries()].filter(([spec]) => isZodSpecifier(spec)); + zodLinksFound += zodLinkers.length; + if (!contract) continue; + + for (const [spec, linkers] of zodLinkers) { + problems.push( + `'${subpath}' is DECLARED browser-reachable but its built graph links '${spec}'\n` + + ` (via ${linkers.join(', ')}).\n` + + ` 浏览器可达的 spec 导出面必须 schema-free (ruling 2026-08-20 on #10096): move the\n` + + ` vocabulary this entry needs into a schema-free module — deriving it at BUILD time\n` + + ` if its source is the zod graph, as gen:meta-url-spelling does — or withdraw the\n` + + ` browser-reachable declaration. Do NOT satisfy this by re-exporting from a wrapper.`, + ); + } + + const allowed = new Set(contract.externals); + for (const [spec, linkers] of graph.externals) { + if (isZodSpecifier(spec) || allowed.has(spec)) continue; + problems.push( + `'${subpath}' is DECLARED browser-reachable and links the undeclared external '${spec}'\n` + + ` (via ${linkers.join(', ')}).\n` + + ` This gate follows relative hops but does not walk node_modules, so an external is\n` + + ` exactly where a zod link can hide behind one level of indirection. Add '${spec}' to\n` + + ` this entry's "externals" in browser-reachable-entries.json — with the reason it is\n` + + ` schema-free — or drop the dependency.`, + ); + } + } + } + + // The calibration. A specifier scan cannot see a zod that got INLINED into the + // bundles, nor an emitted import shape it no longer recognises; in both cases + // it reports every entry clean. It cannot report that silently: zero zod links + // across the whole surface means the instrument, not the surface, changed. + if (bundlesScanned > 0 && zodLinksFound === 0) { + console.error( + `\n❌ check:browser-reachable-entries — INSTRUMENT NOT CALIBRATED.\n\n` + + ` Scanned ${bundlesScanned} built bundle(s) and found ZERO zod links anywhere — including in\n` + + ` the schema-bearing entries that certainly do link it. So this run cannot tell "no entry\n` + + ` links zod" apart from "this scan can no longer see a zod link", and the green it would\n` + + ` otherwise print would be worth nothing.\n\n` + + ` The two causes, both real:\n` + + ` • zod is no longer an external — a bundler change inlined it, so no specifier names it.\n` + + ` This gate's scan must then learn to detect the inlined runtime.\n` + + ` • the emitted import shape moved out from under the scanner (see SPECIFIER_KEYWORDS).\n`, + ); + process.exit(1); + } + + if (problems.length) { + console.error( + `\n❌ check:browser-reachable-entries — ${problems.length} problem(s):\n`, + ); + for (const problem of problems) console.error(` ✗ ${problem}\n`); + console.error( + ` The principle and the reference pattern: packages/spec/README.md ("Export surfaces"),\n` + + ` src/index.ts's module doc, and src/meta-spelling/index.ts.\n`, + ); + process.exit(1); + } + + const declaredCount = Object.keys(judged).length; + console.log( + `✅ check:browser-reachable-entries — ${declaredCount} declared browser-reachable ` + + `entr${declaredCount === 1 ? 'y links' : 'ies link'} no zod; ` + + `${bundlesScanned} bundle(s) scanned, ${zodLinksFound} zod link(s) seen elsewhere ` + + `(instrument calibrated); exports map fully classified.`, + ); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Self-test — the shapes, not the corpus +// --------------------------------------------------------------------------- + +/** + * A green run over today's build proves only what today's build contains, and + * two of the things this gate must do cannot be exercised by it at all: the tree + * has ZERO relative hops between bundles (every entry is self-contained), so the + * transitive walk — the part that keeps the gate honest the day `splitting` is + * turned on — would ship never having run. These fixtures are the contract. + */ +function selfTest(): never { + const failures: string[] = []; + const check = (name: string, ok: boolean, detail = ''): void => { + if (!ok) failures.push(`${name}${detail ? ` — ${detail}` : ''}`); + console.log(` ${ok ? '✓' : '✗'} ${name}${!ok && detail ? ` — ${detail}` : ''}`); + }; + + // ── The scanner: it must FIND real specifiers … ────────────────────────── + const real = [ + `import { z } from 'zod';`, + `export { a } from "./chunk-A.mjs";`, + `import './side-effect.mjs';`, + `var zod = require('zod');`, + `const mod = await import('zod/v4');`, + ].join('\n'); + const realSpecs = specifiersOf(real).map((s) => s.spec); + check( + 'finds static import, re-export, bare import, require and dynamic import', + JSON.stringify(realSpecs) === + JSON.stringify(['zod', './chunk-A.mjs', './side-effect.mjs', 'zod', 'zod/v4']), + JSON.stringify(realSpecs), + ); + + // ── … and it must NOT fabricate them out of prose or lookalikes ────────── + // The first case is REAL text from dist/index.mjs: a documentation string that + // contains a complete import statement. A naive grep reports it as a link. + const ghost = [ + `const doc = { reason: "pins the shape against \\\`import type { X } from '@objectstack/spec/ui'\\\` as intentional" };`, + `// import { z } from 'zod';`, + `/* import { z } from 'zod'; */`, + `const chars = Array.from('abcdef');`, + `const buf = Buffer.from('AAAA');`, + `const cfg = { from: 'zod' };`, + ].join('\n'); + const ghostSpecs = specifiersOf(ghost).map((s) => s.spec); + check( + 'reports nothing for prose, commented-out imports, Array.from/Buffer.from and a `from:` key', + ghostSpecs.length === 0, + JSON.stringify(ghostSpecs), + ); + + // ── The walk: transitive over relative hops, both verdicts ─────────────── + const tmp = mkdtempSync(join(tmpdir(), 'os-browser-reachable-')); + try { + const dist = join(tmp, 'dist'); + mkdirSync(join(dist, 'clean'), { recursive: true }); + mkdirSync(join(dist, 'dirty'), { recursive: true }); + + // Clean entry → chunk → chunk, no zod anywhere. + writeFileSync(join(dist, 'clean', 'index.mjs'), `export { a } from '../chunk-clean.mjs';\n`); + writeFileSync(join(dist, 'chunk-clean.mjs'), `export { a } from './chunk-leaf.mjs';\n`); + writeFileSync(join(dist, 'chunk-leaf.mjs'), `export const a = 1;\n`); + + const clean = walkBundle(tmp, join(dist, 'clean', 'index.mjs')); + check( + 'walks a relative chunk chain to its leaf', + clean.files.length === 3 && clean.externals.size === 0 && clean.unresolved.length === 0, + `files=${clean.files.length} externals=${clean.externals.size}`, + ); + + // Dirty entry: zod is TWO relative hops away — invisible to a one-file scan. + writeFileSync(join(dist, 'dirty', 'index.mjs'), `export { b } from '../chunk-dirty.mjs';\n`); + writeFileSync(join(dist, 'chunk-dirty.mjs'), `export { b } from './chunk-zod.mjs';\n`); + writeFileSync(join(dist, 'chunk-zod.mjs'), `import { z } from 'zod';\nexport const b = z;\n`); + + const dirty = walkBundle(tmp, join(dist, 'dirty', 'index.mjs')); + check( + 'finds a zod link reached through two relative hops', + [...dirty.externals.keys()].some(isZodSpecifier), + JSON.stringify([...dirty.externals.keys()]), + ); + check( + 'a one-file scan would have MISSED it (this is why the walk is transitive)', + specifiersOf(readFileSync(join(dist, 'dirty', 'index.mjs'), 'utf8')).every( + (s) => !isZodSpecifier(s.spec), + ), + ); + + // An unresolvable relative hop is an ERROR, never a silent skip. + writeFileSync(join(dist, 'clean', 'broken.mjs'), `export { c } from './gone.mjs';\n`); + const broken = walkBundle(tmp, join(dist, 'clean', 'broken.mjs')); + check( + 'reports an unresolvable relative hop instead of walking past it', + broken.unresolved.length === 1 && broken.unresolved[0]!.spec === './gone.mjs', + JSON.stringify(broken.unresolved), + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + // ── The refusal: an unbuilt or stale tree is NOT MEASURED ──────────────── + // Driven here rather than against this workspace, because the only way to + // observe the refusal on the real tree is to break the real tree. Both + // verdicts are pinned: a guard only ever seen green cannot be told apart from + // one that matches nothing. + const fresh = mkdtempSync(join(tmpdir(), 'os-browser-reachable-fresh-')); + try { + mkdirSync(join(fresh, 'src'), { recursive: true }); + writeFileSync(join(fresh, 'src', 'a.ts'), 'export const a = 1;\n'); + + const missing = inspectBundleFreshness(fresh, 'check', RERUN); + check( + 'refuses an UNBUILT package rather than reporting it clean', + !missing.fresh && missing.state === 'missing', + JSON.stringify(missing), + ); + + // A bundle older than the source it claims to describe. + mkdirSync(join(fresh, 'dist'), { recursive: true }); + writeFileSync(join(fresh, 'dist', 'index.mjs'), 'export const a = 1;\n'); + const past = new Date(Date.now() - 60_000); + utimesSync(join(fresh, 'dist', 'index.mjs'), past, past); + + const stale = inspectBundleFreshness(fresh, 'check', RERUN); + check( + 'refuses a STALE build rather than answering about it', + !stale.fresh && stale.state === 'stale', + JSON.stringify(stale), + ); + + // …and passes once the bundle is the newer of the two. + const now = new Date(); + utimesSync(join(fresh, 'dist', 'index.mjs'), now, now); + check('accepts a build newer than its sources', inspectBundleFreshness(fresh, 'check', RERUN).fresh); + + // The bundler config is an input too: editing it without rebuilding must + // read as stale, because it decides the entries and the externals. + const later = new Date(Date.now() + 60_000); + writeFileSync(join(fresh, 'tsup.config.ts'), 'export default {};\n'); + utimesSync(join(fresh, 'tsup.config.ts'), later, later); + check( + 'an edited-but-unbuilt tsup.config.ts reads as stale', + !inspectBundleFreshness(fresh, 'check', RERUN).fresh, + ); + } finally { + rmSync(fresh, { recursive: true, force: true }); + } + + // ── The reconciliation: both directions ────────────────────────────────── + const emptyLedger = (): Ledger => ({ browserReachable: {}, unjudged: [], notAModule: [] }); + + let problems: string[] = []; + reconcile({ '.': {}, './new': {} }, { ...emptyLedger(), unjudged: ['.'] }, problems); + check( + 'fails an exports-map entry that no section classifies', + problems.length === 1 && problems[0]!.includes(`'./new'`), + JSON.stringify(problems), + ); + + problems = []; + reconcile({ '.': {} }, { ...emptyLedger(), unjudged: ['.', './gone'] }, problems); + check( + 'fails a ledger row for a subpath the exports map no longer publishes', + problems.length === 1 && problems[0]!.includes(`'./gone'`), + JSON.stringify(problems), + ); + + problems = []; + reconcile( + { '.': {} }, + { browserReachable: { '.': { why: 'x', externals: [] } }, unjudged: ['.'], notAModule: [] }, + problems, + ); + check( + 'fails a subpath classified in two sections at once', + problems.length === 1 && problems[0]!.includes('classified twice'), + JSON.stringify(problems), + ); + + problems = []; + reconcile({ '.': {}, './x.json': {} }, { ...emptyLedger(), unjudged: ['.'], notAModule: ['./x.json'] }, problems); + check('accepts a fully classified exports map', problems.length === 0, JSON.stringify(problems)); + + // ── Target resolution reads BOTH conditions ────────────────────────────── + const targets = targetsOf({ + import: { types: './dist/x/index.d.mts', default: './dist/x/index.mjs' }, + require: { types: './dist/x/index.d.ts', default: './dist/x/index.js' }, + }); + check( + 'scans the import AND require halves of one subpath', + JSON.stringify(targets) === JSON.stringify(['./dist/x/index.mjs', './dist/x/index.js']), + JSON.stringify(targets), + ); + + if (failures.length) { + console.error(`\n✗ self-test: ${failures.length} case(s) failed.`); + process.exit(1); + } + console.log('✅ self-test: scanner, transitive walk and ledger reconciliation all behave.'); + process.exit(0); +} + +if (SELF_TEST) selfTest(); +audit(); diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index 61027d61c3..9844bde938 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -234,6 +234,21 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ check: 'check:dual-source-exports', why: 'audits the built .d.ts for same-name exports resolving to DIFFERENT declarations across entry points — baseline is hand-ratcheted, not generated (needs a fresh `pnpm build`)', }, + // #10199, the mechanized form of the 2026-08-20 ruling on #10096. Reads the + // built dist like the two above, but the BUNDLES rather than the declarations + // — it walks the module graph a consumer's import actually loads and asserts a + // declared browser-reachable entry links no zod. + // + // NO_GENERATOR and not GATED, for `check:dual-source-exports`'s reason exactly: + // `browser-reachable-entries.json` is a hand-maintained CONTRACT, not a + // projection of the source. A `gen:` that rewrote it would grant + // browser-reachability by running a command — which is the one decision the + // ruling reserves for a maintainer — and, in the other direction, would + // "repair" a violation by silently demoting the entry that broke its promise. + { + check: 'check:browser-reachable-entries', + why: 'audits the built .mjs/.js bundles: a declared browser-reachable entry must link no zod in its module graph — the declared list is a hand-written contract, not generated (needs a fresh `pnpm build`)', + }, // Deliberately NOT beside `check:test-typecheck` in GATED above, and the // difference is the whole design of #5475: that gate compares a checked-in // artifact (test-typecheck-debt.json) against a fresh tsc run, so it has a diff --git a/packages/spec/scripts/lib/dist-freshness.ts b/packages/spec/scripts/lib/dist-freshness.ts index 00f4d5cd0d..bae62118d1 100644 --- a/packages/spec/scripts/lib/dist-freshness.ts +++ b/packages/spec/scripts/lib/dist-freshness.ts @@ -97,7 +97,7 @@ import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { distIsStale } from '../../../../scripts/check-regen-pending.mjs'; +import { bundlesAreStale, distIsStale } from '../../../../scripts/check-regen-pending.mjs'; /** What the generator is about to do, so the refusal can name the real damage. */ export type DistReadMode = 'generate' | 'check'; @@ -116,18 +116,22 @@ export type DistFreshness = * A dist with JS and no `.d.ts` is the `OS_SKIP_DTS=1`-on-a-virgin-tree shape, * and it reads as missing rather than stale because that is what it is. */ -function hasDeclarations(dir: string, depth = 0): boolean { +function hasFileMatching(dir: string, pred: (name: string) => boolean, depth = 0): boolean { if (depth > 12 || !existsSync(dir)) return false; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; const child = join(dir, entry.name); if (entry.isDirectory()) { - if (hasDeclarations(child, depth + 1)) return true; - } else if (entry.name.endsWith('.d.ts')) return true; + if (hasFileMatching(child, pred, depth + 1)) return true; + } else if (pred(entry.name)) return true; } return false; } +function hasDeclarations(dir: string): boolean { + return hasFileMatching(dir, (name) => name.endsWith('.d.ts')); +} + /** * The precondition, as data: may this run read `/dist` and believe it? * @@ -180,3 +184,79 @@ export function inspectDistFreshness( ` and skips exactly the declarations this reads.)`, }; } + +/** + * The same precondition for the OTHER half of `dist` — the emitted JS bundles. + * + * ## Why this is a second axis and not a second copy + * + * `inspectDistFreshness` above measures `dist/**\/*.d.ts`. `dist//index.mjs` + * is a different artifact produced by a different pass, and the two can disagree + * in BOTH directions (the rule's own docblock in + * `scripts/check-regen-pending.mjs` has the measurement): + * + * - `BUILD_DTS=true tsup` refreshes declarations and re-emits no bundle, so a + * `.d.ts`-fresh tree can hold bundles older than the edit under test; + * - `OS_SKIP_DTS=1` emits fresh bundles and skips declarations, so a + * `.d.ts`-stale tree can hold bundles that are exactly current. + * + * A bundle reader that asked the `.d.ts` question would therefore be capable of + * believing a stale bundle (direction one) while refusing a current one + * (direction two). So the axes are separate — but they live in ONE home, this + * file, so the next bundle-reading gate finds them together rather than + * inventing a third notion of "is packages/spec/dist current". + * + * ## Why the refusal, and not a skip + * + * #10199's gate asserts that a declared browser-reachable entry links no zod. On + * an unbuilt or stale tree the honest answer is NOT MEASURED, and the two + * failure shapes are the ones #4690 named: a missing `dist` makes "no zod link + * found" and "no bundle read" the same green, and a stale `dist` makes the + * verdict describe a build that predates the import someone just added — a false + * green on precisely the change the gate exists to catch. Both are refusals. + * + * @param mode what this run would do with the bundles — see `DistReadMode`. + * @param rerun the exact command that re-runs THIS caller after the build. + */ +export function inspectBundleFreshness( + pkgDir: string, + mode: DistReadMode, + rerun: string, +): DistFreshness { + if (!bundlesAreStale(pkgDir)) return { fresh: true }; + + const state: 'missing' | 'stale' = hasFileMatching( + join(pkgDir, 'dist'), + (name) => name.endsWith('.mjs') || name.endsWith('.js'), + ) + ? 'stale' + : 'missing'; + + const damage = + mode === 'generate' + ? `Regenerating now would WRITE a ledger describing bundles that no longer match src.` + : `A verdict now would be computed against bundles that no longer match src, so this\n` + + ` check would reach its conclusion without ever reading the module graph under test —\n` + + ` NOT MEASURED reported as if it were measured and clean (#4690).`; + + const cause = + state === 'missing' + ? `packages/spec/dist holds no .mjs/.js bundles — the package is not built. This gate reads\n` + + ` the module a consumer's import actually loads, so there is nothing here to read.` + : `packages/spec/dist's .mjs/.js bundles are OLDER than packages/spec/src (or than\n` + + ` tsup.config.ts, which decides the entries, the externals and whether entries are\n` + + ` self-contained). The bundles on disk predate the sources.`; + + return { + fresh: false, + state, + message: + `\n❌ ${cause}\n\n` + + ` ${damage}\n\n` + + ` Build first, then re-run:\n\n` + + ` pnpm --filter @objectstack/spec build\n` + + ` ${rerun}\n\n` + + ` (OS_SKIP_DTS=1 is fine for THIS gate — it still emits every bundle this reads. It is\n` + + ` the .d.ts-reading gates next door that it blinds.)`, + }; +} diff --git a/scripts/check-regen-pending.d.mts b/scripts/check-regen-pending.d.mts index 2cc0c279af..21a9838761 100644 --- a/scripts/check-regen-pending.d.mts +++ b/scripts/check-regen-pending.d.mts @@ -11,7 +11,7 @@ // guard exists to prevent — would type-check clean. // // Declared rather than inferred (no `allowJs`) because the module sits at the -// repo root, outside the consuming program's `rootDir`. The surface is two +// repo root, outside the consuming program's `rootDir`. The surface is three // functions with one optional argument; keep this file in step with them by // hand, and keep it small enough that doing so stays trivial. @@ -30,3 +30,17 @@ export function distIsStale(specDir?: string): boolean; * @param specDir Absolute path to the spec package; defaults to this repo's. */ export function schemaTreeIsStale(specDir?: string): boolean; + +/** + * Are `packages/spec`'s emitted JS bundles (`dist/**\/*.mjs`, `*.js`) older than + * the sources — or than `tsup.config.ts` — they were bundled from? Missing + * counts as stale. + * + * A DIFFERENT axis from `distIsStale`, which measures the `.d.ts` half: the + * package's second build pass refreshes declarations without re-emitting any + * bundle, so `.d.ts` freshness does not imply bundle freshness. Read the + * function's own docblock before reusing it. + * + * @param specDir Absolute path to the spec package; defaults to this repo's. + */ +export function bundlesAreStale(specDir?: string): boolean; diff --git a/scripts/check-regen-pending.mjs b/scripts/check-regen-pending.mjs index 6cd402e133..1822c08953 100755 --- a/scripts/check-regen-pending.mjs +++ b/scripts/check-regen-pending.mjs @@ -145,6 +145,60 @@ export function schemaTreeIsStale(specDir = SPEC_DIR) { return newestMtime(join(specDir, 'src'), (n) => n.endsWith('.ts') && !n.endsWith('.test.ts')) > tree; } +/** + * The same question for the THIRD build artifact a gate reads: are + * `packages/spec`'s emitted **JS bundles** older than the sources they were + * bundled from? + * + * `check:browser-reachable-entries` (#10199) reads `dist//index.mjs` and + * `index.js` — the module a consumer's `import` actually loads — to assert that + * a declared browser-reachable entry links no zod. That is a different artifact + * from the one `distIsStale` measures, and reusing it would be wrong **in the + * dangerous direction**: `packages/spec`'s build is two tsup passes, and the + * second (`BUILD_DTS=true tsup`, `dts: { only: true }`) refreshes the + * declarations WITHOUT re-emitting a single `.mjs`. A `.d.ts`-fresh tree can + * therefore carry bundles that predate the edit under test, and a zod-link + * verdict computed over those bundles is a FALSE GREEN on exactly the import + * the gate exists to catch. + * + * It is also wrong in the merely annoying direction: `OS_SKIP_DTS=1` — the + * documented local build flag — emits fresh JS and skips the declarations, so + * `distIsStale` reports stale for a tree whose bundles are exactly current, and + * this gate would refuse a build that could have answered. + * + * Three deliberate differences from `distIsStale`: + * - the artifact side matches `.mjs`/`.js`, the emitted bundles (`.d.ts` and + * `.d.mts` end in `.ts`/`.mts`, and sourcemaps in `.map`, so neither is + * counted); + * - `.test.ts` is excluded from the source side, on `schemaTreeIsStale`'s + * reasoning: a test file is not an input to any entry's bundle graph, so + * counting it would send every test-only spec PR to a rebuild it does not + * need, and a guard that cries wolf is a guard someone deletes; + * - `tsup.config.ts` IS counted, though it sits outside `src/`. It is the one + * input whose edit invalidates this gate's measurement most directly — + * `entry`, `splitting` and the external set are all decided there — so an + * edited-but-unbuilt bundler config must read as stale rather than as a + * tree the gate may believe. + * + * Missing counts as stale, and the direction is the same conservative one its + * two siblings take: a false "stale" costs a build, a false "fresh" costs a + * verdict nobody can trust. + */ +export function bundlesAreStale(specDir = SPEC_DIR) { + const bundles = newestMtime( + join(specDir, 'dist'), + (n) => n.endsWith('.mjs') || n.endsWith('.js'), + ); + if (!bundles) return true; + const src = newestMtime( + join(specDir, 'src'), + (n) => n.endsWith('.ts') && !n.endsWith('.test.ts'), + ); + const configPath = join(specDir, 'tsup.config.ts'); + const config = existsSync(configPath) ? statSync(configPath).mtimeMs : 0; + return Math.max(src, config) > bundles; +} + /** * This worktree's git dir — `--absolute-git-dir` resolves to `.git/worktrees/` * in a linked worktree, so the marker and its deferral are per-worktree state and diff --git a/scripts/js-comment-mask.d.mts b/scripts/js-comment-mask.d.mts new file mode 100644 index 0000000000..6dc6c06137 --- /dev/null +++ b/scripts/js-comment-mask.d.mts @@ -0,0 +1,74 @@ +// Types for the code/prose separator `js-comment-mask.mjs` exports to other +// gates — the same problem, and the same fix, as `check-regen-pending.d.mts` +// next door (#5475). +// +// The module itself stays `.mjs`: it carries a `--self-test` entry point run +// with bare `node`, and every root script here is authored that way. What +// changed is that a gate under +// `packages/spec/scripts/` now imports it (`check-browser-reachable-entries.ts`, +// #10199), and since #5475 that directory is inside a tsc program +// (`tsconfig.scripts.json`), where an untyped `.mjs` import is TS7016 — the +// scanner silently becomes `any`, and reading `.comment` off a misspelled +// property would type-check clean. +// +// The two flag arrays are the load-bearing part of the surface, so they are +// typed precisely: a caller that confuses them inverts the very question this +// module exists to answer once (see the module's header on the two failure +// families). +// +// Declared rather than inferred (no `allowJs`) because the module sits at the +// repo root, outside the consuming program's `rootDir`. Keep this file in step +// with the module by hand, and keep it small enough that doing so stays trivial. + +/** + * Per-character flags from one left-to-right pass over a JS source. + * + * Both arrays are the same length as the source, so an offset into either + * indexes the same character in the original text. + * + * `comment` flags comment CONTENT (line, block and shebang). `literal` flags + * the CONTENT of a string, template or regex literal — **not** its delimiters, + * so a caller still sees the opening and closing quote as code and can pair + * them. Template interiors are flagged through `${...}` as well. + */ +export interface SourceFlags { + comment: Uint8Array; + literal: Uint8Array; +} + +/** + * Flag every character of `source` as comment content and/or literal content. + * + * @param source JavaScript (or TypeScript-shaped) source text. + */ +export function scanSource(source: string): SourceFlags; + +/** + * Replace every character flagged in `flags` with a space, keeping newlines — + * so both byte offsets and line numbers survive the mask. + */ +export function blank(source: string, flags: Uint8Array): string; + +/** + * `source` with its COMMENT characters REMOVED and every newline kept: line + * numbers survive, byte offsets do NOT, and the text gets much shorter. + * + * Pick this when the caller feeds a scanner and reports neither an offset nor a + * column — a lazy regex over the whitespace that `maskComments` leaves behind + * is quadratic in the comment bytes (measured: 6.4s → 5m27s on one gate). + */ +export function stripComments(source: string): string; + +/** + * `source` with its COMMENT spans blanked and strings, templates and regex + * literals left INTACT. Offsets and line numbers both survive. + */ +export function maskComments(source: string): string; + +/** + * Drive the scanner over its own fixture corpus, printing a line per case. + * + * Returns nothing: a failing case calls `process.exit(1)` rather than reporting + * a value, so there is no verdict for a caller to forget to read. + */ +export function selfTest(): void;