diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3872738447..34026fdf66 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1329,10 +1329,42 @@ jobs: - name: Shard partitioner self-test run: node scripts/partition-test-shards.mjs --self-test + # Hand-written `.d.mts` mirrors (#10549). `scripts/js-comment-mask.mjs` + # and `scripts/check-regen-pending.mjs` are untyped `.mjs` that each ship + # a hand-written declaration beside them, and both files say "keep this in + # step with the module by hand". Nothing checked that they were. A + # `.d.mts` has no runtime existence, so nothing executes it and nothing + # notices: TypeScript consumers see ONLY the declaration, so a drift makes + # every consumer type-check GREEN against a signature the module does not + # implement, first symptom a runtime failure downstream. That this file + # silently decides typecheck outcomes is measured rather than theoretical + # — PR #10513 went red with `TS2578` on two lanes purely because the + # mirror existed on the merged tree and not on the branch's, and that + # episode cost PR #10450 two merge-queue evictions. This gate asserts + # name, kind and required arity per declared export, and it DISCOVERS its + # corpus (every `scripts/**/*.d.mts`), so a third mirror added tomorrow is + # covered by existing rather than by anyone remembering to enrol it. + # Invoked as `node` rather than through a `pnpm check:*` alias for the + # same reason as the two steps above: that alias belongs in root + # package.json, declared territory of the @changesets/cli v3 lane (#9465) + # while it runs. dispatch-gates.mjs derives gate families from either + # spelling. Imports two small modules; milliseconds. + - name: Hand-written declaration mirrors + run: | + node scripts/check-declaration-mirrors.mjs --self-test + node scripts/check-declaration-mirrors.mjs + # The inventory of `packages/**` tests coupled to `examples/**` (#8754). - # Sibling of the gate above, on the axis it cannot see: that one detects - # tests whose FILESYSTEM READS escape their package, this one detects - # tests that IMPORT an example app live. `packages/cli` dynamically + # Sibling of the cross-package gate above, on the axis that gate does not + # own. ⚠️ The line that used to stand here — "that one detects tests whose + # FILESYSTEM READS escape their package, this one detects tests that + # IMPORT an example app live" — stopped being true in #10452, which taught + # the cross-package gate to read escaping import SPECIFIERS as well as + # path-shaped reads. Both now see the same couplings and do different + # things with them: the cross-package gate turns one into a declared input + # radius plus the turbo glob that hashes it, while this gate keeps the + # `examples/**` INVENTORY and grades what CI can see of each entry. + # `packages/cli` dynamically # imports `examples/app-showcase/src/ui/views/contact.view` and asserts # `toEqual` over a hardcoded `_sections` key list; `packages/lint` # statically imports the same app's `Contact`/`ContactViews`. Neither diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index ea33a5bd1e..afeb5dee8b 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -100,8 +100,46 @@ // path is only a loop variable. Each yields NO name -- never a wrong one; an // unreadable argument costs the name and keeps the depth, so the escape verdict // is unaffected and the roster never gains an entry pointing at a file nobody -// reads. Reads that reach another package through Node's RESOLVER rather than -// through `fs` are outside this gate entirely. +// reads. +// +// ── The other way out of a package: the RESOLVER (#10452) ─────────────────── +// +// Everything above is a path-shaped file read, seeded from `import.meta.url` or +// `__dirname`. An ES module specifier is none of those -- it is a bare string in +// `import` position that the module resolver, not `node:path`, turns into a +// file -- so "reads that reach another package through Node's RESOLVER are +// outside this gate entirely" was this file's stated boundary, and a test that +// IMPORTS across the package boundary went undeclared silently. +// +// Measured on `2d3860df9a`, not reasoned: two live `packages/cli` contract tests +// import `maskComments` from `../../../../scripts/js-comment-mask.mjs`. With the +// hand-added glob for it removed, this gate printed `OK: 12 package(s) read +// outside themselves, all declared` and exited 0 -- so an edit to that module +// would not have re-run cli's suite, which is #7802 exactly, by another spelling. +// The declaration was added by hand in PR #10450 precisely because the gate did +// not demand it. +// +// So specifiers are now walked by the same `walkLiteral`, in the same two +// coordinates, judged on the same shallowest point (RECOGNISED_IMPORT_SPELLINGS, +// published beside the path list). Two things make it a different read rather +// than a wider regex: +// +// The BOUNDARY. Only a RELATIVE specifier is collected. A bare one +// (`@objectstack/verify`) is an installed dependency resolved through +// `node_modules`, which no glob can hash -- the same exclusion `vendored` +// already makes. Getting this wrong would put every package's suite on every +// workspace sibling. +// +// The NAME. A specifier is not a path: under NodeNext `../x.js` is `../x.ts` +// on disk, and three cli tests import the showcase app with no extension at +// all. `resolveImportTarget()` maps the recognised extension rules back onto a +// real file, and a specifier matching none of them keeps its escape verdict +// and loses its name, exactly as an unreadable path argument does. +// +// This found six couplings nothing had ever declared: `@objectstack/client`'s +// route-ledger conformance tests import five sibling packages' `src/` directly, +// and `@objectstack/rest`, the three services and `plugin-auth` are not even +// dependencies of it -- so no graph edge reached them and no glob hashed them. // // Usage: // node scripts/check-cross-package-test-inputs.mjs --verify @@ -281,6 +319,68 @@ const CROSS_PACKAGE_TEST_INPUTS = { 'scripts/check-nul-bytes.mjs', 'scripts/js-comment-mask.mjs', 'scripts/js-comment-mask.d.mts', + // `translation.zod.ts` is the second entry no test READS -- named in a + // comment in test/i18n-section-coverage.test.ts, which describes it as the + // DECLARATION face of the schema that test asserts against. It appears + // here only now because that file had no `fs` read at all, so it never + // reached the scan before #10452 relaxed the pre-filter to admit + // import-only escapes; the flat literal collector then took the quoted + // path exactly as it always has. Settled the same way as + // `check-nul-bytes.mjs` above -- declaring one file beats teaching the + // scanner to tell prose from code, and this one costs nothing in practice: + // `@objectstack/spec` is a real dependency of this package, so the graph + // already re-runs these tests on any spec change. + 'packages/spec/src/system/translation.zod.ts', + ], + }, + '@objectstack/client': { + // The first entry this gate DERIVED from import specifiers rather than from + // a path-shaped read (#10452), and the reason that half was worth building: + // five tests here import six sibling packages' route ledgers directly by + // relative specifier, and nothing had ever declared any of them. + // src/client-url-conformance.test.ts and src/route-ledger-response-schema.test.ts + // import runtime, rest, service-storage, service-i18n and plugin-auth; + // src/route-ledger-coverage.test.ts imports runtime; + // src/rest-route-ledger-coverage.test.ts imports rest; + // src/service-route-ledger-coverage.test.ts imports the three services, + // service-datasource among them. + // Each asserts this client's URL builders still agree with the ledger the + // server side publishes, so a ledger edit changes the verdict by design. + // + // The graph does not carry it and cannot be made to: of the six, only + // `@objectstack/runtime` appears in this package's manifest at all (a + // devDependency) -- `@objectstack/rest`, the three services and + // `plugin-auth` are not dependencies in any form, which is why + // `turbo ls --affected` could not reach client from a ledger-only diff and + // `client#test` hashed the same before and after one. #7802's shape exactly, + // reached by the other spelling. + // + // Per-file rather than `packages/**/src/**`: a ledger is one file per + // package and these tests read nothing else across the boundary, so the + // radius stays the six files the imports name. The roster holds it -- an + // import added outside them fails this gate by name. + globs: [ + 'packages/runtime/src/route-ledger.ts', + 'packages/rest/src/rest-route-ledger.ts', + 'packages/services/service-storage/src/storage-route-ledger.ts', + 'packages/services/service-i18n/src/i18n-route-ledger.ts', + 'packages/services/service-datasource/src/datasource-route-ledger.ts', + 'packages/plugins/plugin-auth/src/auth-route-ledger.ts', + // Below this line: paths these tests NAME in prose rather than read. Each + // docblock cross-references the sibling conformance test it mirrors, or + // the script that records the envelope shape, and the flat literal + // collector takes quoted paths without parsing. Same designed trade as the + // `check-nul-bytes.mjs` entry on `@objectstack/cli` -- over-collection can + // only widen a radius, never narrow one, and declaring the file beats + // rewording a comment to dodge a scanner. Not claimed as real inputs: a + // sibling package's TEST file cannot change this package's verdict. The + // six globs above are the ones the imports hold. + 'packages/runtime/src/route-ledger.conformance.test.ts', + 'packages/rest/src/rest-route-ledger.conformance.test.ts', + 'packages/services/service-storage/src/storage-route-ledger.conformance.test.ts', + 'packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts', + 'packages/services/service-datasource/src/datasource-route-ledger.conformance.test.ts', + 'scripts/check-route-envelope.mjs', ], }, '@objectstack/lint': { @@ -369,6 +469,15 @@ const CROSS_PACKAGE_TEST_INPUTS = { 'packages/rest/src/**', 'packages/runtime/src/**', 'packages/services/service-realtime/src/**', + // The three ledgers test/route-ledger-live-mount-parity.dogfood.test.ts + // IMPORTS, which no read named and nothing declared until #10452 taught + // this gate specifiers. That test mounts the live app and asserts every + // ledger entry is really routed, so each ledger is an input by + // construction. Per-file, matching what the imports name: the rest of + // these services' `src/**` is not read here. + 'packages/services/service-storage/src/storage-route-ledger.ts', + 'packages/services/service-i18n/src/i18n-route-ledger.ts', + 'packages/services/service-settings/src/settings-route-ledger.ts', // flow-trigger / validation conformance pin spec's zod schemas. 'packages/spec/src/automation/**', 'packages/spec/src/data/**', @@ -538,6 +647,8 @@ export function coversDirectory(dir, globs, root = REPO_ROOT) { // ── the escape detector ────────────────────────────────────────────────────── const FS_READ = /\b(readFileSync|readdirSync|statSync|existsSync|globSync|opendirSync|execFileSync)\b/; +/** A quoted literal that climbs — the cheapest necessary condition for an escaping import (#10452). */ +const ASCENDING_LITERAL = /(['"])\.\.\//; const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.turbo', '.next', '.git']); /** @@ -568,6 +679,124 @@ export const RECOGNISED_PATH_SPELLINGS = [ "readFileSync(new URL('', import.meta.url)) // position", ]; +/** + * The IMPORT spellings this gate can SEE, in the words an author would write + * them (#10452). Published for the same reason as the list above, and printed + * beside it in the failure text. + * + * An ES module specifier is none of the shapes above it: it is a bare string in + * `import` position that the module RESOLVER, not `node:path`, turns into a + * file. So until this list existed the gate's stated boundary — "reads that + * reach another package through Node's RESOLVER rather than through `fs` are + * outside this gate entirely" — held, and a test importing across the package + * boundary went undeclared silently. Measured on `2d3860df9a`: with + * `scripts/js-comment-mask.mjs` deleted from `@objectstack/cli`'s globs, and two + * live tests importing `maskComments` from it, this gate printed + * `OK: 12 package(s) read outside themselves, all declared` and exited 0. + * + * ⚠️ The boundary that makes this safe: only specifiers that START RELATIVE + * (`./`, `../`) are read. A BARE specifier (`@objectstack/verify`, `node:fs`) is + * an installed dependency resolved through `node_modules` — the same thing + * `walkLiteral`'s `vendored` flag already drops, for the same reason: no turbo + * glob can name it, and collecting them would put every package's suite on every + * workspace sibling. A relative specifier that ESCAPES is the opposite case: it + * names a repo source file a glob can hash, and nothing else was seeing it. + */ +export const RECOGNISED_IMPORT_SPELLINGS = [ + "import { x } from '../'; // static — `import type` counts too, it", + ' // is an input to the typecheck verdict', + "export { x } from '../'; // re-export, and `export * from`", + "import '../'; // side-effect import", + "await import('../'); // dynamic, with a LITERAL specifier", + "require('../'); // cjs (no test spells it this way today)", + ' ⛔ NOT `@objectstack/` // a BARE specifier is an installed', + ' // dependency, never a repo source input', +]; + +/** + * Every string-literal module specifier in `src`, in the four positions a + * specifier can occupy. The pattern set is the one `check-examples-live-imports` + * already proved on this same corpus — that gate reads test imports for the + * `examples/**` axis, and this is the same read widened to every target. + * + * Deliberately NOT comment-masked, which is where this gate parts company with + * that sibling. Masking is a read that can only SHRINK what is collected, and a + * spelling wrongly masked is a live import gone silent — the one failure mode + * this file exists to not have. Not masking can only over-collect, and this gate + * settles that trade the same way everywhere else: a mention forces a WIDER + * declaration, never a narrower one (see the `check-nul-bytes.mjs` roster entry, + * declared for exactly that reason). + * + * Measured on this tree across 2509 test sources: of the 4174 relative + * specifiers this finds, 6 exist only inside comments — and not one of those 6 + * escapes its package, so none reaches the roster at all. The over-collection + * this trade accepts is real but currently costs nothing, and it is bounded in + * the safe direction by construction: a commented-out specifier can only force + * a declaration nobody needed, never withdraw one a live import holds. + */ +export function importSpecifiers(src) { + const out = new Set(); + const patterns = [ + // `from ''` covers `import … from` and `export … from`, multiline + // clauses included — a clause never contains a quote, so the literal that + // follows `from` is the specifier. + /\bfrom\s*(['"])([^'"\n]+)\1/g, + /\bimport\s*(['"])([^'"\n]+)\1/g, + /\bimport\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g, + /\brequire\s*\(\s*(['"])([^'"\n]+)\1\s*\)/g, + ]; + for (const re of patterns) for (const m of src.matchAll(re)) out.add(m[2]); + return out; +} + +/** + * The specifier extensions this gate can map back to a file ON DISK. + * + * Under `moduleResolution: NodeNext` a TypeScript source is imported with the + * extension of the file it will EMIT, so `../x.js` is `../x.ts` on disk — while + * a root script really is `.mjs` and resolves as itself. Extensionless + * specifiers occur too. Measured on this tree, over every relative specifier + * that escapes its package: 15 extensionless (`packages/client`'s five + * route-ledger conformance tests, which import six sibling packages that way), + * 10 `.js` naming a `.ts` (`packages/lint` and cli, into `examples/`), and 3 + * literal `.mjs` (the two cli tests of #10452, plus `packages/spec`'s + * `schema-tree-freshness.test.ts` reaching `scripts/check-regen-pending.mjs`). + * Each rule below is pinned by a `--self-test` case against a real file, so a + * rule that stops resolving fails here rather than going quiet. + * + * ⚠️ "Extensionless" is judged against the KNOWN module extensions, never + * against "the last segment contains a dot". This repo's authored metadata is + * `contact.view.ts`, `semantic-zoo.object.ts`, `task-triage.page.ts`, and it is + * imported as `../../../examples/app-showcase/src/ui/views/contact.view` — a + * trailing-dot-segment test reads `.view` as an extension, appends nothing, and + * the specifier resolves to nothing. Measured: that spelling is exactly the + * three `packages/cli` i18n-coverage imports, whose globs were on the roster by + * HAND. Getting this wrong does not fail loudly; it silently declines to hold a + * radius somebody already wrote down. + * + * A specifier matching none of them keeps its ESCAPE verdict and loses its NAME + * — the same trade `walkLiteral` makes for an argument it cannot read, and for + * the same reason: a roster entry pointing at a file nobody reads is worse than + * a missing one. The author still gets a red gate naming the test. + */ +const KNOWN_MODULE_EXTENSION = /\.(?:[cm]?[jt]sx?|json|node)$/; + +function resolveImportTarget(name) { + const candidates = [name]; + if (/\.js$/.test(name)) candidates.push(name.replace(/\.js$/, '.ts'), name.replace(/\.js$/, '.tsx')); + else if (/\.mjs$/.test(name)) candidates.push(name.replace(/\.mjs$/, '.mts')); + else if (/\.cjs$/.test(name)) candidates.push(name.replace(/\.cjs$/, '.cts')); + else if (!KNOWN_MODULE_EXTENSION.test(name)) candidates.push(`${name}.ts`, `${name}.tsx`, `${name}.mts`); + for (const c of candidates) { + try { + if (statSync(join(REPO_ROOT, c)).isFile()) return c; + } catch { + // Not this candidate — try the next. + } + } + return null; +} + function walkTests(dir, out = []) { let entries; try { @@ -828,6 +1057,12 @@ function scanPathExpressions(src, hereDepth, fileSegs = null) { const escapes = []; const files = new Set(); const dirs = new Set(); + // Module specifiers this file imports from outside the package, as the + // repo-relative names they SPELL — mapped onto real files by the caller. + const imports = new Set(); + // An import resolves against the importing file's DIRECTORY, which is what + // `fileSegs` names one level below. + const dirSegs = fileSegs ? fileSegs.slice(0, -1) : null; const report = (name, info) => { // `vendored`: the read escapes the package but lands in an installed // dependency, which no declaration can name. Not a cross-package input. @@ -861,7 +1096,23 @@ function scanPathExpressions(src, hereDepth, fileSegs = null) { if (known.has(first)) continue; report(`read #${n} argument`, info); } - return { escapes, files, dirs }; + + // The RESOLVER half (#10452). A relative specifier resolves against the + // importing FILE's directory — the same base as the two seeds and as + // `new URL(rel, import.meta.url)` — so it is the same `walkLiteral` walk in + // the same two coordinates, and the escape verdict is the same shallowest + // point. What differs is only that the name it produces is a MODULE + // specifier, so it goes in its own bucket for `findEscapingPackages()` to map + // back onto a file (`resolveImportTarget`); everything else here is shared. + for (const spec of importSpecifiers(src)) { + // ⚠️ The boundary. Anything not starting `.` is a bare specifier: an + // installed dependency, which no declared glob can name. + if (!spec.startsWith('.')) continue; + const info = walkLiteral(hereDepth, spec, dirSegs); + report(`import '${spec}'`, info); + if (info.segs?.length && !info.vendored) imports.add(info.segs.join('/')); + } + return { escapes, files, dirs, imports }; } /** @@ -914,7 +1165,13 @@ export function findEscapingPackages() { if (!existsSync(dir)) continue; for (const file of walkTests(dir)) { const src = readFileSync(file, 'utf8'); - if (!FS_READ.test(src)) continue; + // Two ways out of a package, so two cheap pre-filters. The second is what + // lets a test that ONLY imports across the boundary be seen at all: before + // #10452 a file with no `fs` call never reached the scan, so the import + // half would have been unreachable no matter how well it resolved. An + // escaping specifier must contain an ascending relative literal, which is + // all this asks before paying for the walk. + if (!FS_READ.test(src) && !ASCENDING_LITERAL.test(src)) continue; const pkgRoot = packageRootOf(file); if (!pkgRoot) continue; const hereDepth = relative(pkgRoot, dirname(file)).split(sep).filter(Boolean).length; @@ -938,8 +1195,14 @@ export function findEscapingPackages() { // expressions RESOLVE to — the reads that hold a radius without ever // spelling it (#9763). A reconstructed directory counts only when a // directory-listing read consumed it; everything else must name a file. + // A third source, same filter: the modules the test IMPORTS from outside + // the package (#10452). A specifier is mapped onto the file it really + // resolves to first — `../x.js` is `../x.ts` on disk under NodeNext — and + // one that resolves to nothing drops out here rather than entering the + // roster as a name nobody reads. + const imported = [...scan.imports].map((p) => resolveImportTarget(p)).filter((p) => p !== null); const roster = [ - ...[...repoRelativeLiterals(src), ...scan.files].map((p) => [p, 'file']), + ...[...repoRelativeLiterals(src), ...scan.files, ...imported].map((p) => [p, 'file']), ...[...scan.dirs].map((p) => [p, 'dir']), ]; for (const [lit, kind] of roster) { @@ -1061,6 +1324,12 @@ function verify() { ' --self-test case) rather than working around it — an unseen read is the\n' + ' defect above, not a style question.', ); + console.error( + '\nA test reaches outside its package by IMPORTING as well as by reading, and\n' + + 'those specifiers are read too (#10452). The recognised list, same rule — a\n' + + 'spelling that is not here yields no flag:\n' + + RECOGNISED_IMPORT_SPELLINGS.map((s) => ` ${s}`).join('\n'), + ); process.exit(1); } console.log( @@ -1460,6 +1729,106 @@ function selfTest() { at("const P = resolve(__dirname, '../../scripts/x.mjs');", 1), ); + // ── the RESOLVER half (#10452) ───────────────────────────────────────────── + // + // One case per entry in RECOGNISED_IMPORT_SPELLINGS, which is the rule this + // file publishes for its path spellings and now owes its import spellings + // too: a list of what the gate can see is a claim, and a claim nothing runs + // is the phantom check this repo keeps re-learning. Adding a spelling to that + // array without a case here should feel like the omission it is. + // + // Then the BOUNDARY, in its own cases and deliberately over-covered. Reading + // a bare specifier as an escape would put every package's suite on every + // workspace sibling — the one way this half could do more damage than the + // blind spot it closes — so `@objectstack/*`, `node:*` and a plain package + // name are each pinned NOT to flag, rather than trusting one case to stand + // for the class. + const specOf = (src, depth, fileSegs) => + [...scanPathExpressions(src, depth, fileSegs).imports].map((p) => resolveImportTarget(p)).filter((p) => p !== null); + // `packages/cli/src/commands/x.contract.test.ts` — the #10452 specimen, two + // directories below its package root. + const CLI = ['packages', 'cli', 'src', 'commands', 'x.contract.test.ts']; + + ok('flags a static import that escapes the package (the #10452 specimen)', at("import { maskComments } from '../../../../scripts/js-comment-mask.mjs';", 2)); + ok( + 'flags an `import type` — it is an input to the typecheck verdict', + at("import type { RouteLedgerEntry } from '../../runtime/src/route-ledger';", 1), + ); + ok('flags a re-export (`export … from`)', at("export { ROUTE_LEDGER } from '../../runtime/src/route-ledger';", 1)); + ok('flags a star re-export (`export * from`)', at("export * from '../../runtime/src/route-ledger';", 1)); + ok('flags a side-effect import with no clause', at("import '../../../../scripts/js-comment-mask.mjs';", 2)); + ok('flags a dynamic import with a literal specifier', at("const m = await import('../../../../scripts/js-comment-mask.mjs');", 2)); + ok('flags a cjs require with a literal specifier', at("const { maskComments } = require('../../../../scripts/js-comment-mask.mjs');", 2)); + + // ⛔ The boundary. An installed dependency is not a repo source input and no + // turbo glob can name it, which is the same exclusion `vendored` already + // makes for path reads. + ok('does NOT flag a bare workspace specifier', !at("import { verify } from '@objectstack/verify';", 2)); + ok('does NOT flag a node: builtin', !at("import { readFileSync } from 'node:fs';", 2)); + ok('does NOT flag an unscoped package name', !at("import { describe, it } from 'vitest';", 2)); + ok('does NOT flag a same-directory relative import', !at("import { helper } from './helper.js';", 2)); + ok( + 'does NOT flag an ascent that stays inside the package', + !at("import { fixture } from '../fixtures/app.js';", 2), + ); + ok( + 'a bare specifier contributes no roster name either', + specOf("import { verify } from '@objectstack/verify';", 2, CLI).length === 0, + ); + + // The NAME half. Each case pins the repo-relative path the specifier must + // produce AGAINST A REAL FILE, so an extension rule that stops resolving + // fails here instead of quietly dropping a package's radius. A case asserting + // only "something came out" would pass just as happily on a wrong name. + ok( + 'a literal .mjs specifier resolves as itself (cli -> the comment masker)', + specOf("import { maskComments } from '../../../../scripts/js-comment-mask.mjs';", 2, CLI).includes('scripts/js-comment-mask.mjs'), + ); + ok( + 'an extensionless specifier resolves to the .ts on disk (client -> runtime`s ledger)', + specOf("import { ROUTE_LEDGER } from '../../runtime/src/route-ledger';", 1, [ + 'packages', + 'client', + 'src', + 'client-url-conformance.test.ts', + ]).includes('packages/runtime/src/route-ledger.ts'), + ); + ok( + 'a NodeNext .js specifier resolves to the .ts on disk (dogfood -> runtime`s ledger)', + specOf("import { ROUTE_LEDGER } from '../../../runtime/src/route-ledger.js';", 1, [ + 'packages', + 'qa', + 'dogfood', + 'test', + 'route-ledger-live-mount-parity.dogfood.test.ts', + ]).includes('packages/runtime/src/route-ledger.ts'), + ); + // ⚠️ The metadata spelling. `contact.view` is extensionless as a SPECIFIER + // while its last segment carries a dot, so a "does it end in .something" test + // appends no candidate and the name is lost. Measured before the fix: this + // exact import went unnamed, and the glob holding it was on the roster only + // because a human had written it there. + ok( + 'an extensionless specifier whose last segment contains a dot still resolves (cli -> a .view)', + specOf("import { contactView } from '../../../examples/app-showcase/src/ui/views/contact.view';", 1, [ + 'packages', + 'cli', + 'test', + 'i18n-section-coverage.test.ts', + ]).includes('examples/app-showcase/src/ui/views/contact.view.ts'), + ); + // The same trade `walkLiteral` makes for an unreadable argument: no name, but + // the escape verdict survives, so the author still gets a red gate naming the + // test rather than a silent pass. + ok( + 'a specifier that resolves to no file yields no name', + specOf("import { x } from '../../../no-such-dir-10452/x';", 1, CLI).length === 0, + ); + ok( + 'but it still flags the escape', + at("import { x } from '../../../no-such-dir-10452/x';", 1), + ); + // The `skills/` prefix -- the one spelling of #9763 that is a DATA fix in the // flat collector rather than a reconstruction, kept pinned on both sides so a // future trim of the alternation cannot pass. diff --git a/scripts/check-declaration-mirrors.mjs b/scripts/check-declaration-mirrors.mjs new file mode 100644 index 0000000000..80f10697b0 --- /dev/null +++ b/scripts/check-declaration-mirrors.mjs @@ -0,0 +1,558 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-declaration-mirrors -- keeps a hand-written `.d.mts` in step with the +// `.mjs` module it declares, for the root scripts that publish types by hand. +// +// ── The defect this exists to make impossible (#10549) ─────────────────────── +// +// `scripts/js-comment-mask.mjs` and `scripts/check-regen-pending.mjs` are +// untyped `.mjs`, and each ships a hand-written `scripts/.d.mts` beside +// it. That was a deliberate trade (#5475, #10398): the modules stay `.mjs` +// because `pre-commit` and the gates invoke them with bare `node`, while +// `packages/spec/scripts/` imports them from inside a tsc program where an +// untyped `.mjs` import is TS7016. Both declaration files say so, and both say +// the same thing about maintenance -- "Keep this file in step with the module +// by hand". +// +// Nothing checked that they were. Not a test, not a gate, not a build step. A +// TypeScript consumer sees ONLY the declaration, so on drift every consumer +// type-checks GREEN against a signature the module does not implement, and the +// first symptom is a runtime failure somewhere downstream. +// +// That is worse than the three sibling shapes this tree hit the same week +// (#9901's options type, #10078's `/meta` scope maps, #10063's `publishMetaItem` +// declared type), and worse in a specific way: in each of those the declaration +// and the value disagreed, but something EXECUTED the value and went red. A +// `.d.mts` has no runtime existence at all. Nothing executes it, so nothing can +// notice on its own. +// +// ── Why the cost is measured rather than hypothetical ─────────────────────── +// +// PR #10513 demonstrated that one of these files silently decides a typecheck +// result across trees. The branch forked at `5c3faa70d`; `js-comment-mask.d.mts` +// landed on `main` in `0681a76b8` after that. On the branch's tree the import +// had no types, so an `@ts-expect-error` on it was load-bearing and `tsc` was +// clean; on CI's merged tree the import resolved WITH types, the directive was +// unused, `TS2578`, two typecheck lanes red -- and `@objectstack/cli`'s ledger +// entry has `surplus: none`, so `typecheck-debt` went red with them. That was +// base drift rather than signature drift, but it is the same file deciding a +// typecheck outcome, and it cost PR #10450 two merge-queue evictions. +// +// A signature drift is strictly worse than that episode, because it fails +// GREEN. +// +// ── What this asserts, and what it deliberately does not ──────────────────── +// +// The corpus is DISCOVERED, never listed: every `scripts/**/*.d.mts` is checked +// against its sibling module. A third mirror added tomorrow is covered by +// existing here, which is the property a hand-kept list would not have. +// +// For each pair, per declared export: +// +// NAME -- a declared value export the module does not export at all. This +// is the fail-green direction above, and the reason this gate +// exists: a rename on the module side leaves every consumer +// type-checking clean against a symbol that resolves to +// `undefined` at runtime. +// KIND -- `export function` must BE a function at runtime. +// ARITY -- the declared REQUIRED parameter count must equal the module's +// `Function.length`, which is exactly "parameters before the first +// one with a default or a rest". So `distIsStale(specDir?: string)` +// agrees with `distIsStale(specDir = SPEC_DIR)` -- 0 required on +// both sides -- and a newly REQUIRED parameter on either side is a +// disagreement. +// +// ⛔ What it does NOT assert: parameter and return TYPES. `maskComments` +// returning `string[]` where the declaration says `string` is invisible to any +// runtime check, and a gate that overstates its coverage is worse than one that +// states its limit -- so the limit is stated here rather than discovered later. +// Those stay hand-maintained, and stay cheap because both mirrors are small by +// design. +// +// ⛔ One direction is deliberately NOT fatal: a module export that the +// declaration omits. `check-regen-pending.mjs` exports seven functions and +// declares three, on purpose -- its declaration says "The surface is three +// functions". That partial mirror cannot fail green: a consumer importing an +// undeclared name gets `TS2305`, which is loud, red and immediate. Failing on +// it here would turn a safe, deliberate design into a red gate, and would grow +// the hand-maintained surface this gate exists to shrink. +// +// An export spelling this parser cannot classify is an ERROR, never a silent +// skip -- the same rule the cross-package gate publishes for its own scan. A +// declaration that yields NO exports is an error too: absence must be loud +// (AGENTS.md, Route & surface ownership §3), or a masking bug would read as a +// clean pass over a file nothing checked. +// +// ── One self-reference, measured and accepted rather than discovered ──────── +// +// This gate imports `maskComments` from `./js-comment-mask.mjs`, which is one +// of the two modules in its own corpus. So a drift in THAT module's masking +// export is the one case this gate cannot report on: measured by ablation, a +// `maskComments` turned into a non-function makes this file die with +// `TypeError: maskComments is not a function` at `parseDeclaration` instead of +// printing a verdict. +// +// Accepted, for a reason that has to be checked rather than assumed: the crash +// is LOUD. It exits non-zero, so CI is red either way, and this family's whole +// danger is the failure that goes green. What is lost is the message quality, +// not the signal. The alternative -- a second copy of the comment masker living +// here -- would add an untested duplicate of the exact thing five gates were +// consolidated onto, to improve an error string in a case that already fails. +// The other mirror (`check-regen-pending`) is not a dependency of this file, so +// all three rules stay demonstrable on a module this gate does not import. +// +// Usage: +// node scripts/check-declaration-mirrors.mjs +// node scripts/check-declaration-mirrors.mjs --self-test + +import { readFileSync, readdirSync, existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { join, resolve, relative, dirname, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { tmpdir } from 'node:os'; +import process from 'node:process'; +import { maskComments } from './js-comment-mask.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..'); +const SCRIPTS_DIR = join(REPO_ROOT, 'scripts'); + +/** + * The export spellings this parser recognises, in the words a declaration + * author would write them. Published for the same reason the cross-package + * gate publishes its path spellings: an unrecognised spelling must be a red + * gate naming itself, not a silent skip, and the author needs to know what the + * parser reads before it tells them it could not read something. + */ +export const RECOGNISED_DECLARATION_SPELLINGS = [ + 'export function name(a: T, b?: U): R; // and `export declare function`', + 'export const name: T; // and `export declare const`', + 'export class Name { … }', + 'export interface Name { … } // type-only: no runtime existence', + 'export type Name = …; // likewise', +]; + +/** Every `scripts/**\/*.d.mts`, repo-relative, sorted. Discovered, never listed. */ +export function mirrorFiles(dir = SCRIPTS_DIR) { + const out = []; + const walk = (d) => { + let entries; + try { + entries = readdirSync(d, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + const p = join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith('.d.mts')) out.push(p); + } + }; + walk(dir); + return out.sort(); +} + +/** + * Scan from `open` (an index of `(`) to its matching `)`. Returns the inside, + * or null. + * + * ⚠️ Only `()`, `[]` and `{}` move the depth here — deliberately NOT `<>`. An + * angle bracket is not a bracket: `>` is also the tail of `=>`, and a callback + * parameter is the ordinary way to spell one. Counting it closed this scan at + * the arrow, so `f(cb: (x: number) => void, y: string)` handed back the inside + * as `cb: (x: number) =` and the arity came out 0 instead of 2 — silently, with + * nothing in `unrecognised` to say so. That is this gate's own failure mode + * turned on itself: a wrong number believed, rather than a spelling refused. + * Generic arguments inside a parameter list keep their parens balanced, so + * ignoring `<>` costs this scan nothing. + */ +function balanced(src, open) { + let depth = 0; + for (let i = open; i < src.length; i++) { + const c = src[i]; + if (c === '(' || c === '[' || c === '{') depth += 1; + else if (c === ')' || c === ']' || c === '}') { + depth -= 1; + if (depth === 0) return src.slice(open + 1, i); + } + } + return null; +} + +/** + * Split a parameter list on its TOP-LEVEL commas — a param type may hold its + * own (`m: Map` is ONE parameter). + * + * So `<>` DOES count here, unlike in `balanced` above, and for the same reason + * it must not there: a `>` preceded by `=` is an arrow, never a closing angle. + * Without that exception `cb: (x: number) => void, y: string` splits into one + * parameter instead of two. + */ +function splitParams(text) { + const out = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '(' || c === '[' || c === '{' || c === '<') depth += 1; + else if (c === ')' || c === ']' || c === '}') depth -= 1; + else if (c === '>' && text[i - 1] !== '=') depth -= 1; + else if (c === ',' && depth === 0) { + out.push(text.slice(start, i).trim()); + start = i + 1; + } + } + const last = text.slice(start).trim(); + if (last) out.push(last); + return out.filter(Boolean); +} + +/** + * The count of LEADING required parameters — the same thing `Function.length` + * reports at runtime, which is why the two are comparable at all. A parameter + * is not required once it is optional (`name?`), has a default (`= x`), or is a + * rest (`...rest`), and TypeScript forbids a required one after an optional, so + * counting leading ones loses nothing. + * + * ⚠️ The default-value test excludes `=>` for the third time in this file's + * bracket handling: an arrow inside a parameter TYPE is not a default value on + * the parameter. Miss it and `cb: (x: number) => void` reads as defaulted, the + * loop breaks at the first parameter, and the arity is silently 0. + */ +export function requiredArity(paramText) { + let n = 0; + for (const p of splitParams(paramText)) { + if (p.startsWith('...')) break; + const name = p.split(':')[0].trim(); + if (name.endsWith('?')) break; + if (/(^|[^=!<>])=([^=>]|$)/.test(p)) break; + n += 1; + } + return n; +} + +/** + * Parse one declaration source into its exports. + * + * Comments are masked first (`js-comment-mask`, the repo's one code/prose + * separator): these files are mostly prose, and their prose says things like + * "the two flag arrays are the load-bearing part of the surface" — an + * `export`-shaped word in a docblock must not read as a declaration. The + * zero-export check in `checkPair()` is what keeps that masking honest: if it + * ever erased too much, the result is a loud error, not a clean pass. + * + * @returns {{ exports: Array<{name: string, kind: string, arity: number|null, line: number}>, + * unrecognised: Array<{text: string, line: number}> }} + */ +export function parseDeclaration(src) { + const masked = maskComments(src); + const lineOf = (i) => masked.slice(0, i).split('\n').length; + const exports = []; + const unrecognised = []; + + for (const m of masked.matchAll(/\bexport\b/g)) { + const at = m.index; + const rest = masked.slice(at); + // `export declare function f(…)` / `export function f(…)` + const fn = rest.match(/^export\s+(?:declare\s+)?function\s+([A-Za-z_$][\w$]*)\s*(?:<[^(]*>)?\s*\(/); + if (fn) { + const open = at + rest.indexOf('(', fn[0].length - 1); + const params = balanced(masked, open); + exports.push({ name: fn[1], kind: 'function', arity: params === null ? null : requiredArity(params), line: lineOf(at) }); + continue; + } + const cls = rest.match(/^export\s+(?:declare\s+)?class\s+([A-Za-z_$][\w$]*)/); + if (cls) { + exports.push({ name: cls[1], kind: 'function', arity: null, line: lineOf(at) }); + continue; + } + const cnst = rest.match(/^export\s+(?:declare\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)/); + if (cnst) { + exports.push({ name: cnst[1], kind: 'value', arity: null, line: lineOf(at) }); + continue; + } + const typeOnly = rest.match(/^export\s+(?:interface|type)\s+([A-Za-z_$][\w$]*)/); + if (typeOnly) { + exports.push({ name: typeOnly[1], kind: 'type', arity: null, line: lineOf(at) }); + continue; + } + unrecognised.push({ text: rest.slice(0, rest.search(/[\n;{]/) + 1).trim() || 'export', line: lineOf(at) }); + } + return { exports, unrecognised }; +} + +/** Compare one `.d.mts` against the module it mirrors. @returns {string[]} problems */ +export async function checkPair(declPath, moduleFor = (p) => p.replace(/\.d\.mts$/, '.mjs')) { + const problems = []; + const declRel = relative(REPO_ROOT, declPath).split(sep).join('/'); + const modPath = moduleFor(declPath); + const modRel = relative(REPO_ROOT, modPath).split(sep).join('/'); + + if (!existsSync(modPath)) { + problems.push( + `${declRel} declares a module that does not exist (${modRel}).\n` + + ` A declaration mirroring nothing is drift by itself: it keeps type-checking\n` + + ` consumers green over an import that cannot resolve at runtime.`, + ); + return problems; + } + + const { exports: declared, unrecognised } = parseDeclaration(readFileSync(declPath, 'utf8')); + + for (const u of unrecognised) { + problems.push( + `${declRel}:${u.line} uses an export spelling this gate cannot read: \`${u.text}\`\n` + + ` An unread declaration is an UNCHECKED declaration, so this is an error rather\n` + + ` than a skip. Teach the parser the spelling (with a --self-test case), or write\n` + + ` one of:\n` + + RECOGNISED_DECLARATION_SPELLINGS.map((s) => ` ${s}`).join('\n'), + ); + } + + if (declared.length === 0) { + problems.push( + `${declRel} declares nothing this gate could find.\n` + + ` Absence must be loud: an empty parse is indistinguishable from a clean pass,\n` + + ` and that is the failure mode this gate exists to not have.`, + ); + return problems; + } + + let mod; + try { + mod = await import(pathToFileURL(modPath).href); + } catch (e) { + problems.push(`${modRel} could not be imported, so its declaration cannot be checked: ${e.message}`); + return problems; + } + + for (const d of declared) { + // A type-only export has no runtime existence — which is precisely why this + // family goes unnoticed, and precisely why there is nothing to assert here. + if (d.kind === 'type') continue; + + if (!(d.name in mod)) { + problems.push( + `${declRel}:${d.line} declares \`${d.name}\`, but ${modRel} does not export it.\n` + + ` This is the direction that fails GREEN: every TypeScript consumer sees the\n` + + ` declaration and only the declaration, so it type-checks clean and calls a\n` + + ` symbol that is \`undefined\` at runtime. Rename it here, or export it there.`, + ); + continue; + } + if (d.kind === 'function' && typeof mod[d.name] !== 'function') { + problems.push( + `${declRel}:${d.line} declares \`${d.name}\` as a function, but ${modRel} exports ` + + `\`${typeof mod[d.name]}\`.\n` + + ` A consumer calling it type-checks clean and throws at runtime.`, + ); + continue; + } + if (d.arity !== null && typeof mod[d.name] === 'function' && mod[d.name].length !== d.arity) { + problems.push( + `${declRel}:${d.line} declares \`${d.name}\` with ${d.arity} required parameter(s), but ` + + `${modRel} implements ${mod[d.name].length}.\n` + + ` \`Function.length\` counts parameters before the first default or rest, so an\n` + + ` optional declared parameter (\`x?: T\`) agrees with a defaulted one (\`x = v\`).\n` + + ` A disagreement here means a caller the declaration admits is one the module\n` + + ` cannot serve.`, + ); + } + } + return problems; +} + +async function main() { + const files = mirrorFiles(); + if (files.length === 0) { + console.error( + 'FAIL: no `scripts/**/*.d.mts` found at all.\n' + + ' This gate discovers its corpus rather than listing it, so an empty corpus is\n' + + ' either a moved directory or a broken walk — never a pass.', + ); + process.exit(1); + } + + const problems = []; + for (const f of files) problems.push(...(await checkPair(f))); + + if (problems.length) { + console.error('FAIL: a hand-written declaration disagrees with the module it mirrors.\n'); + for (const p of problems) console.error(` - ${p}\n`); + console.error( + 'Why this gate exists: a `.d.mts` has no runtime existence, so nothing executes it\n' + + 'and nothing notices when it drifts. TypeScript consumers see only the declaration,\n' + + 'which means drift type-checks GREEN against a signature the module does not\n' + + 'implement, and the first symptom is a runtime failure downstream (#10549).\n', + ); + process.exit(1); + } + + const pairs = files.map((f) => relative(REPO_ROOT, f).split(sep).join('/')); + console.log( + `OK: ${files.length} hand-written declaration(s) agree with their modules on name, kind ` + + `and required arity.\n ${pairs.join('\n ')}\n` + + ` (Parameter and return TYPES are not asserted — see this file's header.)`, + ); +} + +// ── self-test ─────────────────────────────────────────────────────────────── + +async function selfTest() { + const cases = []; + const ok = (label, cond) => cases.push({ label, cond }); + const dir = mkdtempSync(join(tmpdir(), 'os-decl-mirror-')); + let seq = 0; + + /** Write a `.d.mts`/`.mjs` pair and return the problems `checkPair` reports. */ + const pair = async (decl, mod) => { + const base = join(dir, `case${(seq += 1)}`); + writeFileSync(`${base}.d.mts`, decl); + if (mod !== null) writeFileSync(`${base}.mjs`, mod); + return checkPair(`${base}.d.mts`); + }; + + // The agreeing shape, which every rejection below is measured against. + ok( + 'an agreeing pair reports nothing', + (await pair('export function mask(source: string): string;\n', 'export function mask(source) { return source; }\n')).length === 0, + ); + + // ── the fail-GREEN direction, the whole point of the gate ── + ok( + 'a declared export the module does not export is a problem', + (await pair('export function maskComments(source: string): string;\n', 'export function mask(source) { return source; }\n')).some((p) => + p.includes('does not export it'), + ), + ); + ok( + 'and the message names the fail-green mechanism rather than only the symbol', + (await pair('export function gone(a: string): string;\n', 'export function here(a) { return a; }\n')).some((p) => + p.includes('fails GREEN'), + ), + ); + + // ── arity, in both directions ── + ok( + 'a newly required parameter on the module side is a problem', + (await pair('export function f(a: string): string;\n', 'export function f(a, b) { return a + b; }\n')).some((p) => + p.includes('required parameter'), + ), + ); + ok( + 'a newly required parameter on the DECLARATION side is a problem', + (await pair('export function f(a: string, b: string): string;\n', 'export function f(a) { return a; }\n')).some((p) => + p.includes('required parameter'), + ), + ); + // The `check-regen-pending` shape: `specDir?: string` against `specDir = SPEC_DIR`. + // Both are 0 required, so this must NOT fire — the case that would make the + // gate unusable on the very corpus it ships for. + ok( + 'an optional declared parameter agrees with a defaulted implementation', + (await pair('export function distIsStale(specDir?: string): boolean;\n', 'export function distIsStale(specDir = "x") { return !specDir; }\n')).length === 0, + ); + ok( + 'a rest parameter is not counted as required', + (await pair('export function f(...args: string[]): string;\n', 'export function f(...args) { return args[0]; }\n')).length === 0, + ); + ok( + 'a parameter whose TYPE holds a comma is still one parameter', + (await pair('export function f(a: Record): void;\n', 'export function f(a) { return a; }\n')).length === 0, + ); + // ⚠️ The ARROW cases. `>` is the tail of `=>` as well as a closing angle, and + // reading it as a bracket made this parser hand back a truncated parameter + // list and report arity 0 — quietly, with nothing in `unrecognised`. That is + // a wrong number BELIEVED, which is the shape this gate exists to refuse, so + // both halves are pinned: the count must be right, and a genuinely + // disagreeing module must still be caught through the same spelling. Neither + // mirror uses a callback parameter today; the day one does, this holds. + ok( + 'a callback parameter does not collapse the arity (the `=>` is not a bracket)', + (await pair('export function f(cb: (x: number) => void, y: string): void;\n', 'export function f(cb, y) { return cb(y); }\n')).length === 0, + ); + ok( + 'and a real disagreement behind a callback parameter is still caught', + (await pair('export function f(cb: (x: number) => void, y: string): void;\n', 'export function f(cb) { return cb; }\n')).some((p) => + p.includes('required parameter'), + ), + ); + ok( + 'an arrow return type is not read as a default value', + (await pair('export function f(cb: (m: Map) => void): void;\n', 'export function f(cb) { return cb; }\n')).length === 0, + ); + + // ── kind ── + ok( + 'a declared function that is a value at runtime is a problem', + (await pair('export function f(a: string): string;\n', 'export const f = "not a function";\n')).some((p) => p.includes('exports `string`')), + ); + ok( + 'a declared const need only exist', + (await pair('export const FLAGS: number;\n', 'export const FLAGS = 3;\n')).length === 0, + ); + + // ── the type-only exports, which have no runtime existence to check ── + ok( + 'an exported interface is not required at runtime', + (await pair('export interface SourceFlags { comment: Uint8Array; }\nexport function f(a: string): string;\n', 'export function f(a) { return a; }\n')).length === 0, + ); + ok( + 'an exported type alias is not required at runtime', + (await pair('export type Flags = number;\nexport function f(a: string): string;\n', 'export function f(a) { return a; }\n')).length === 0, + ); + + // ── the direction that is deliberately NOT fatal ── + ok( + 'a module export the declaration omits is NOT a problem (it fails red at the consumer)', + (await pair('export function f(a: string): string;\n', 'export function f(a) { return a; }\nexport function extra() { return 1; }\n')).length === 0, + ); + + // ── loud on anything unread ── + ok( + 'an unrecognised export spelling is an error, not a skip', + (await pair('export default function f(a: string): string;\n', 'export default function f(a) { return a; }\n')).some((p) => + p.includes('cannot read'), + ), + ); + ok( + 'a declaration that declares nothing is an error', + (await pair('// only prose, no declarations at all\n', 'export function f(a) { return a; }\n')).some((p) => p.includes('declares nothing')), + ); + ok( + 'a declaration with no module beside it is an error', + (await pair('export function f(a: string): string;\n', null)).some((p) => p.includes('does not exist')), + ); + + // ── comment masking: prose must not read as a declaration ── + ok( + 'an `export` word inside a comment is not parsed as a declaration', + parseDeclaration('// this module exports things; export function ghost(): void; is prose\nexport function real(a: string): string;\n').exports.length === 1, + ); + ok( + 'and the one real declaration is the one found', + parseDeclaration('// export function ghost(): void;\nexport function real(a: string): string;\n').exports[0].name === 'real', + ); + + // ── the corpus is discovered, and it is not empty on this tree ── + ok('the corpus walk finds this repo\'s mirrors', mirrorFiles().length >= 2); + ok( + 'and it finds them under scripts/ by extension, not by a hand-kept list', + mirrorFiles().every((f) => f.endsWith('.d.mts')), + ); + + const failed = cases.filter((c) => !c.cond); + for (const c of cases) console.log(`${c.cond ? 'ok ' : 'FAIL'} ${c.label}`); + if (failed.length) { + console.error(`\n${failed.length}/${cases.length} self-test case(s) failed.`); + process.exit(1); + } + console.log(`\nAll ${cases.length} self-test cases passed.`); +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) await selfTest(); + else await main(); +} diff --git a/turbo.json b/turbo.json index 8210abfd77..ce24d57b19 100644 --- a/turbo.json +++ b/turbo.json @@ -74,7 +74,30 @@ "$TURBO_ROOT$/content/docs/permissions/authentication.mdx", "$TURBO_ROOT$/scripts/check-nul-bytes.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.mjs", - "$TURBO_ROOT$/scripts/js-comment-mask.d.mts" + "$TURBO_ROOT$/scripts/js-comment-mask.d.mts", + "$TURBO_ROOT$/packages/spec/src/system/translation.zod.ts" + ] + }, + "@objectstack/client#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/runtime/src/route-ledger.ts", + "$TURBO_ROOT$/packages/rest/src/rest-route-ledger.ts", + "$TURBO_ROOT$/packages/services/service-storage/src/storage-route-ledger.ts", + "$TURBO_ROOT$/packages/services/service-i18n/src/i18n-route-ledger.ts", + "$TURBO_ROOT$/packages/services/service-datasource/src/datasource-route-ledger.ts", + "$TURBO_ROOT$/packages/plugins/plugin-auth/src/auth-route-ledger.ts", + "$TURBO_ROOT$/packages/runtime/src/route-ledger.conformance.test.ts", + "$TURBO_ROOT$/packages/rest/src/rest-route-ledger.conformance.test.ts", + "$TURBO_ROOT$/packages/services/service-storage/src/storage-route-ledger.conformance.test.ts", + "$TURBO_ROOT$/packages/services/service-i18n/src/i18n-route-ledger.conformance.test.ts", + "$TURBO_ROOT$/packages/services/service-datasource/src/datasource-route-ledger.conformance.test.ts", + "$TURBO_ROOT$/scripts/check-route-envelope.mjs" ] }, "@objectstack/lint#test": { @@ -148,6 +171,9 @@ "$TURBO_ROOT$/packages/rest/src/**", "$TURBO_ROOT$/packages/runtime/src/**", "$TURBO_ROOT$/packages/services/service-realtime/src/**", + "$TURBO_ROOT$/packages/services/service-storage/src/storage-route-ledger.ts", + "$TURBO_ROOT$/packages/services/service-i18n/src/i18n-route-ledger.ts", + "$TURBO_ROOT$/packages/services/service-settings/src/settings-route-ledger.ts", "$TURBO_ROOT$/packages/spec/src/automation/**", "$TURBO_ROOT$/packages/spec/src/data/**", "$TURBO_ROOT$/examples/app-showcase/**",