From c76fa971870e0abe53b72bf86d76f276eba50e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 01:57:14 +0000 Subject: [PATCH] test(i18n,app-shell): discover the createSafeTranslation defaults tables instead of hand-listing three (objectui#7884) The rule "every row names a key the en pack actually defines" is the one rule in this repo that rejects a defaults row whose key no locale pack defines. It did not miss objectui#7874's five dead `timeline.relative.*` rows because it was wrong -- it never saw them: its `MAPS` was a hand-written list of three IMPORTED maps, and `TIMELINE_DEFAULT_TRANSLATIONS` was not on it. Measured: the three-map list judged 400 of 1056 rows (37.9%). It was also structurally incompletable by its own mechanism -- 11 of the 32 factory call sites pass an anonymous inline object literal exported under no name at all, so no list of imports could ever reach them. The AST walk that discovers these tables already existed, module-private inside `fallback-placeholder-spelling-3512.test.ts`. It is MOVED, not copied, into `@object-ui/test-support`, so both gates ask their different questions of one definition of the population instead of two that drift apart. Behaviour of the objectui#3512 gate is preserved, verified on one tree either side of the move: 35 discovery entries, 1072 rows, 0 unreadable, the same 4 needle files, and an identical sha256 over every row (f7910279e114cd6e1bf1d2b97bf7164661283cd9fbb5eef88286f2f75bea8065). The scanner is reached as a DECLARED subpath, `@object-ui/test-support/ defaults-table-scan`, and NOT from the package index. It reads the workspace from disk, so it only type-checks where Node's ambient types are present, and a barrel re-export puts a module into the program of every consumer that imports the index for anything at all. Measured with it on the index: `data-objectstack` imports `{ enumOptions }` from that index in one test, so `tsc -p packages/data-objectstack/tsconfig.json --listFiles` pulled this file into that package's program and the repo-wide type-check failed with three TS2591s in a file that package never asked for. `./zod-wrapper-keys` is the same escape hatch for the mirror case, and objectui#4325's lesson is untouched: the hazard there was an UNDECLARED deep path, TS2882 for `tsc`; this one is in `exports`. One spelling also had to change. The walk skips `__tests__` directories, which is why the needle could be a single literal while it lived in one; in a normal `src/` module a verbatim spelling makes the scanner match its own source and report itself as a fifth interpolator. It is assembled from fragments now, with the runtime value unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .../defaults-maps-mirror-discovery-7884.md | 9 + .../defaults-maps-mirror-en-pack.test.tsx | 132 +++++- packages/i18n/package.json | 1 + ...fallback-placeholder-spelling-3512.test.ts | 306 +------------ packages/test-support/package.json | 2 + .../test-support/src/defaults-table-scan.ts | 427 ++++++++++++++++++ packages/test-support/src/index.ts | 27 ++ packages/test-support/tsconfig.json | 8 +- pnpm-lock.yaml | 6 + 9 files changed, 630 insertions(+), 288 deletions(-) create mode 100644 .changeset/defaults-maps-mirror-discovery-7884.md create mode 100644 packages/test-support/src/defaults-table-scan.ts diff --git a/.changeset/defaults-maps-mirror-discovery-7884.md b/.changeset/defaults-maps-mirror-discovery-7884.md new file mode 100644 index 0000000000..3e2c7c8dc8 --- /dev/null +++ b/.changeset/defaults-maps-mirror-discovery-7884.md @@ -0,0 +1,9 @@ +--- +--- + +Test-only change (objectui#7884): the `defaults-maps-mirror-en-pack` gate's rule "every +row names a key the en pack actually defines" now runs over the `createSafeTranslation` +defaults tables discovered from source, instead of a hand-written list of three imported +maps that judged 400 of 1056 rows. The AST walk objectui#3512 already had moved into +`@object-ui/test-support` (private, never published) so both gates share one definition of +the population rather than growing a second traversal. No published behaviour changes. diff --git a/packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx b/packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx index 7716718b54..733da29bd8 100644 --- a/packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx +++ b/packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx @@ -7,8 +7,48 @@ */ /** - * The three ungated `createSafeTranslation` defaults maps mirror the `en` pack — - * objectui#4401, generalizing objectui#3440's collaboration-only precedent. + * The `createSafeTranslation` defaults maps mirror the `en` pack — + * objectui#4401, generalizing objectui#3440's collaboration-only precedent, + * widened from three hand-listed maps to the discovered population by + * objectui#7884. + * + * ## What objectui#7884 changed, and why it was not a new rule + * + * The case "every row names a key the en pack actually defines" below is the + * ONE rule in this repo that rejects a defaults row whose key no pack defines. + * It did not fail to catch objectui#7874's five dead `timeline.relative.*` rows + * because it was wrong — it never saw them. Its `MAPS` was a hand-written list + * of three IMPORTED maps, and `TIMELINE_DEFAULT_TRANSLATIONS` was not on it: a + * correct rule held outside the door by a hand-written list, the same family as + * objectui#7448 / #7528 / #7548 / #7825 / #7853. + * + * Measured before the widening: the three-map list judged **400 of 1056 rows, + * 37.9%** — the rule was held outside 62% of its own population. And the list + * was not merely incomplete, it was **structurally incompletable by its own + * mechanism**: 11 of today's 32 factory tables are anonymous inline object + * literals passed straight to `createSafeTranslation(…)` and exported under no + * name at all, so no amount of diligence in maintaining a list of IMPORTS could + * ever reach them. With objectui#7874's five rows retired this class is empty + * repo-wide: 0 offending rows across 34 unique tables and 1056 rows, and a + * blind spot of 0 — nothing was unreadable, so the zero is a real zero. + * + * So the rule now runs over the population `@object-ui/test-support`'s + * `scanDefaultsTables` discovers from source — the same walk objectui#3512's + * `fallback-placeholder-spelling-3512.test.ts` already used, MOVED rather than + * copied, because two traversals are two definitions of the population that + * drift apart. + * + * ## The two reading paths are kept, deliberately + * + * The three hand-listed maps stay, and are still read as RUNTIME OBJECTS + * through their package imports, while the discovered population is read as + * SOURCE TEXT through the AST. That is not duplication: on the three maps they + * overlap, each is a check on the other, and a case below pins that every row + * of every imported map is present in the discovered set. Only the + * key-existence rule is widened — the byte-identity comparison still needs the + * runtime object, and judging factory-table VALUES against the pack is + * objectui#7567's `factory-default-drift`, a different question with its own + * deliberate abstention. * * ## The defect this pins * @@ -75,6 +115,9 @@ */ import { describe, it, expect } from 'vitest'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { scanDefaultsTables } from '@object-ui/test-support/defaults-table-scan'; import { en } from '@object-ui/i18n'; import { DETAIL_DEFAULT_TRANSLATIONS } from '@object-ui/plugin-detail'; import { LIST_DEFAULT_TRANSLATIONS } from '@object-ui/plugin-list'; @@ -89,6 +132,33 @@ const packValueAt = (key: string): unknown => /** Codepoints, so a failure distinguishes `…` from `...` and NBSP from a space. */ const codepoints = (s: string) => [...s].map((c) => c.codePointAt(0)).join(','); +const here = path.dirname(fileURLToPath(import.meta.url)); +// packages/app-shell/src/__tests__ -> repo root +const REPO_ROOT = path.resolve(here, '../../../..'); + +/** + * The discovered population — every `createSafeTranslation(…)` first argument + * resolved from source, plus the three hand-rolled sibling tables. One walk, + * shared with objectui#3512's gate; see `@object-ui/test-support`'s + * `defaults-table-scan.ts` for why it is not a second traversal. + */ +const DISCOVERED = scanDefaultsTables(REPO_ROOT); + +/** + * Rows de-duplicated on `file:line` + key. + * + * `TIMELINE_DEFAULT_TRANSLATIONS` is discovered TWICE on purpose — once as the + * factory call in `useTimelineTranslation.ts` and once through the hand-rolled + * registry, which objectui#3512 keeps so the registry mirrors its needle-file + * set. Its rows are therefore scanned twice, and a gate that reported them raw + * would inflate every count it prints (objectui#7874's five rows read as ten). + * The identity is the ROW's own location plus its key, not the table label, + * because the two discoveries of that table carry different labels. + */ +const DISCOVERED_ROWS = [ + ...new Map(DISCOVERED.rows.map((row) => [`${row.where}::${row.key}`, row])).values(), +]; + interface MapUnderGate { /** Name used in test titles and failure messages. */ readonly name: string; @@ -237,3 +307,61 @@ describe('the plugin defaults maps mirror the en pack (objectui#4401)', () => { } }); }); + +describe('the discovered defaults tables name keys the en pack defines (objectui#7884)', () => { + it('discovers the population, and states its blind-spot size', () => { + // Non-vacuity, #4118 family standard: the rule below is "no row offends", + // which an empty or broken walk satisfies trivially. Floors, not pins — a + // new table raises them for free, only a table DISAPPEARING has to be + // explained. + expect(DISCOVERED.tables.length).toBeGreaterThanOrEqual(34); + expect(DISCOVERED_ROWS.length).toBeGreaterThanOrEqual(1_000); + expect(DISCOVERED.sourceFiles.length).toBeGreaterThan(1_000); + + // THE blind spot, asserted rather than counted in silence. A table that + // does not resolve, a computed key, a non-static value: each is a row this + // instrument cannot judge, and an instrument that hides how much it cannot + // see reads as 100% coverage forever. Measured 0 when this landed — + // objectui#7567's census surfaced objectui#7874 precisely because it + // printed its abstention count. + expect(DISCOVERED.unreadable).toEqual([]); + }); + + it('every row of every hand-listed map is present in the discovered set', () => { + // Ties the two reading paths together. The maps above are IMPORTED runtime + // objects; the population here is read from SOURCE by the AST. If the walk + // ever stops reaching one of these three tables, this fails loudly instead + // of quietly shrinking the population the rule below judges. + const discoveredByFile = new Map>(); + for (const row of DISCOVERED_ROWS) { + const file = row.where.slice(0, row.where.lastIndexOf(':')); + let keys = discoveredByFile.get(file); + if (!keys) discoveredByFile.set(file, (keys = new Set())); + keys.add(row.key); + } + + const missed = MAPS.flatMap(({ map, source }) => + Object.keys(map) + .filter((key) => !(discoveredByFile.get(source)?.has(key) ?? false)) + .map((key) => `${source} row ${key} is not in the discovered population`), + ); + expect(missed).toEqual([]); + }); + + it('every row names a key the en pack actually defines', () => { + // THE widened rule. Same verdict objectui#4401 wrote for three maps, now + // asked of every discovered table: a row whose key the pack lacks means the + // provider path cannot serve this string at all (i18next answers with the + // call site's `defaultValue`, or the raw key), so the two paths disagree by + // construction. If this goes red, FILE the row — never add the key to an + // allow-list, and never re-narrow the population to make it green. + const missing = DISCOVERED_ROWS.filter( + ({ key }) => typeof packValueAt(key) !== 'string', + ).map( + ({ where, key, table }) => + `${where} ${key} — row of ${table}, absent from the en pack — FINDING, file it`, + ); + + expect(missing).toEqual([]); + }); +}); diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 36d0db90bf..da0d8d7ba5 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -44,6 +44,7 @@ "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@object-ui/test-support": "workspace:*", "@types/react": "19.2.18", "react": "19.2.8", "typescript": "^6.0.3", diff --git a/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts b/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts index 6c2c858947..403eaf9fba 100644 --- a/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts +++ b/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts @@ -134,10 +134,12 @@ */ import { describe, it, expect } from 'vitest'; -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import { + HAND_ROLLED_TABLES, + scanDefaultsTables, +} from '@object-ui/test-support/defaults-table-scan'; import { builtInLocales } from '../locales'; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -229,233 +231,22 @@ const PACK_LEAVES = new Map(LOCALE_CODES.map((code) => [code, leaves(builtInLoca /* Copy source 2/3 — the defaults tables, read from source */ /* ------------------------------------------------------------------ */ -/** The factory, and plugin-detail's re-export alias for it. */ -const FACTORY_NAMES = new Set(['createSafeTranslation', 'createSafeTranslationHook']); - -/** - * The literal needle, as it is spelled in source: ``.split(`{{${``. The - * completeness case below pins which files carry it, so a fourth hand-rolled - * copy of `fallbackT` forces an edit here instead of escaping the gate. - */ -const NEEDLE_IN_SOURCE = '.split(`{{${'; - -/** Every runtime `.ts`/`.tsx` under the workspace — tests and tooling excluded. */ -function collectSourceFiles(): string[] { - const out: string[] = []; - const walk = (dir: string) => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const name = entry.name; - if ( - name === 'node_modules' || - name === 'dist' || - name === '__tests__' || - name === '__mocks__' || - name.startsWith('.') - ) { - continue; - } - const full = path.join(dir, name); - if (entry.isDirectory()) walk(full); - else if (/\.tsx?$/.test(name) && !/\.(test|spec|bench|stories)\.tsx?$/.test(name)) { - out.push(full); - } - } - }; - for (const root of ['packages', 'apps', 'examples']) { - const full = path.join(REPO_ROOT, root); - if (existsSync(full) && statSync(full).isDirectory()) walk(full); - } - return out.sort(); -} - -const SOURCE_FILES = collectSourceFiles(); -const rel = (abs: string) => path.relative(REPO_ROOT, abs); - -const parsed = new Map(); -function sourceFileFor(abs: string): ts.SourceFile | null { - if (parsed.has(abs)) return parsed.get(abs) ?? null; - const sf = existsSync(abs) - ? ts.createSourceFile( - abs, - readFileSync(abs, 'utf8'), - ts.ScriptTarget.Latest, - true, - abs.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, - ) - : null; - parsed.set(abs, sf); - return sf; -} - -/** Peel the wrappers a table declaration may carry before its object literal. */ -function unwrap(node: ts.Expression): ts.Expression { - let e = node; - for (;;) { - if (ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e)) { - e = e.expression; - continue; - } - return e; - } -} - -/** The initializer of a top-level `const = …` in this file. */ -function constInitializer(sf: ts.SourceFile, name: string): ts.Expression | null { - let found: ts.Expression | null = null; - const visit = (node: ts.Node) => { - if (found) return; - if ( - ts.isVariableDeclaration(node) && - ts.isIdentifier(node.name) && - node.name.text === name && - node.initializer - ) { - found = node.initializer; - return; - } - ts.forEachChild(node, visit); - }; - ts.forEachChild(sf, visit); - return found; -} - -/** Follow `import { } from './relative'` to the file that declares it. */ -function importedFrom(sf: ts.SourceFile, name: string): string | null { - let spec: string | null = null; - ts.forEachChild(sf, (node) => { - if (spec !== null) return; - if ( - ts.isImportDeclaration(node) && - node.importClause?.namedBindings && - ts.isNamedImports(node.importClause.namedBindings) && - ts.isStringLiteral(node.moduleSpecifier) - ) { - for (const element of node.importClause.namedBindings.elements) { - if (element.name.text === name) spec = node.moduleSpecifier.text; - } - } - }); - if (spec === null || !(spec as string).startsWith('.')) return null; - const base = path.resolve(path.dirname(sf.fileName), spec); - for (const candidate of [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) { - if (existsSync(candidate)) return candidate; - } - return null; -} - -/** - * A string a table row is declared with. Handles the `'a' + 'b'` concatenation - * one row uses (`plugin-form/src/occSave.tsx`); anything else returns - * `undefined` and is REPORTED rather than skipped — a row the gate cannot read - * is a hole in it, not an exemption. - */ -function staticString(node: ts.Expression): string | undefined { - const e = unwrap(node); - if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text; - if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = staticString(e.left); - const right = staticString(e.right); - if (left !== undefined && right !== undefined) return left + right; - } - return undefined; -} - -/** One row of one gated table, located precisely enough to fix. */ -interface Row { - readonly table: string; - readonly where: string; - readonly key: string; - readonly value: string; -} - -interface TableScan { - readonly rows: Row[]; - /** Rows whose value is not a static string, and tables that never resolved. */ - readonly unreadable: string[]; -} - -function scanObjectLiteral( - literal: ts.ObjectLiteralExpression, - table: string, - into: TableScan, - keyPrefix = '', -): void { - const owner = literal.getSourceFile(); - const at = (node: ts.Node) => - `${rel(owner.fileName)}:${owner.getLineAndCharacterOfPosition(node.getStart()).line + 1}`; - for (const property of literal.properties) { - if (ts.isPropertyAssignment(property)) { - const name = - ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) - ? property.name.text - : null; - if (name === null) { - into.unreadable.push(`${at(property)} — computed key in ${table}`); - continue; - } - const key = keyPrefix ? `${keyPrefix}.${name}` : name; - const initializer = unwrap(property.initializer); - if (ts.isObjectLiteralExpression(initializer)) { - scanObjectLiteral(initializer, table, into, key); - continue; - } - const value = staticString(initializer); - if (value === undefined) { - into.unreadable.push(`${at(property)} — ${table}.${key} is not a static string`); - continue; - } - into.rows.push({ table, where: at(property), key, value }); - } else { - into.unreadable.push(`${at(property)} — non-assignment member in ${table}`); - } - } -} - -/** - * Resolve a `createSafeTranslation` first argument to its object literal: an - * inline table, a `const` in the same file, or a `const` imported from a - * relative module. - */ -function resolveTableArgument( - sf: ts.SourceFile, - argument: ts.Expression, -): { literal: ts.ObjectLiteralExpression | null; name: string } { - const unwrapped = unwrap(argument); - if (ts.isObjectLiteralExpression(unwrapped)) { - return { literal: unwrapped, name: '(inline table)' }; - } - if (ts.isIdentifier(unwrapped)) { - const name = unwrapped.text; - let initializer = constInitializer(sf, name); - if (initializer === null) { - const from = importedFrom(sf, name); - const imported = from === null ? null : sourceFileFor(from); - if (imported) initializer = constInitializer(imported, name); - } - if (initializer !== null) { - const literal = unwrap(initializer); - if (ts.isObjectLiteralExpression(literal)) return { literal, name }; - } - return { literal: null, name }; - } - return { literal: null, name: ts.SyntaxKind[unwrapped.kind] }; -} - -/** - * The three tables whose packages re-implemented `fallbackT`'s literal needle - * rather than taking the factory. Each file states its own reason for that; - * none of them changes the grammar the needle accepts, so the rule is the same. - * `TIMELINE_DEFAULT_TRANSLATIONS` also reaches the factory — listed anyway, so - * the registry mirrors the needle-file set the completeness case pins. +/* + * The walk that discovers these tables moved to + * `@object-ui/test-support`'s `defaults-table-scan.ts` in objectui#7884, so + * that the objectui#4401 gate — "every defaults row names a key the `en` pack + * actually defines", which used to read a hand-written list of three imported + * maps and therefore judged 400 of 1056 rows — asks its question of exactly the + * population this file asks its own question of. Two traversals would be two + * definitions of that population, drifting apart. Nothing about the discovery + * changed in the move: same factory names, same hand-rolled registry, same + * `unreadable` reporting. Verified byte-identical either side of the move on the + * same tree — 35 discovery entries, 1072 rows, 0 unreadable, the same 4 needle + * files, and the same sha256 over every row. (The walked-FILE count matched too, + * but it is not quoted here: it moves by one with `apps/site/next-env.d.ts`, + * which is generated at install time, so it is a property of the checkout rather + * than of the tree. The case below floors it instead of pinning it.) */ -const HAND_ROLLED_TABLES: readonly { readonly file: string; readonly name: string }[] = [ - { file: 'packages/plugin-gantt/src/useGanttTranslation.ts', name: 'GANTT_DEFAULT_TRANSLATIONS' }, - { file: 'packages/plugin-grid/src/ImportWizard.tsx', name: 'IMPORT_DEFAULT_TRANSLATIONS' }, - { - file: 'packages/plugin-timeline/src/useTimelineTranslation.ts', - name: 'TIMELINE_DEFAULT_TRANSLATIONS', - }, -]; /** * Files that carry the literal needle today — the completeness case's subject. @@ -473,62 +264,7 @@ const NEEDLE_FILES = [ 'packages/plugin-timeline/src/useTimelineTranslation.ts', ]; -function scanDefaultsTables(): TableScan & { readonly tables: string[]; readonly needle: string[] } { - const scan: TableScan = { rows: [], unreadable: [] }; - const tables: string[] = []; - const needle: string[] = []; - - for (const abs of SOURCE_FILES) { - const text = readFileSync(abs, 'utf8'); - if (text.includes(NEEDLE_IN_SOURCE)) needle.push(rel(abs)); - if (!text.includes('createSafeTranslation')) continue; - const sf = sourceFileFor(abs); - if (!sf) continue; - const visit = (node: ts.Node) => { - if (ts.isCallExpression(node)) { - const callee = node.expression; - const name = ts.isIdentifier(callee) - ? callee.text - : ts.isPropertyAccessExpression(callee) - ? callee.name.text - : null; - if (name !== null && FACTORY_NAMES.has(name) && node.arguments.length > 0) { - const line = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1; - const { literal, name: tableName } = resolveTableArgument(sf, node.arguments[0]); - const label = `${tableName} (${rel(abs)}:${line})`; - if (literal === null) { - // Not an exemption: a table the gate cannot reach is a table the - // gate does not cover, and that has to be visible. - scan.unreadable.push(`${rel(abs)}:${line} — cannot resolve ${tableName} to a table`); - } else { - tables.push(label); - scanObjectLiteral(literal, label, scan); - } - } - } - ts.forEachChild(node, visit); - }; - ts.forEachChild(sf, visit); - } - - for (const { file, name } of HAND_ROLLED_TABLES) { - const abs = path.join(REPO_ROOT, file); - const sf = sourceFileFor(abs); - const initializer = sf === null ? null : constInitializer(sf, name); - const literal = initializer === null ? null : unwrap(initializer); - if (literal === null || !ts.isObjectLiteralExpression(literal)) { - scan.unreadable.push(`${file} — hand-rolled table ${name} no longer resolves`); - continue; - } - const label = `${name} (${file})`; - tables.push(label); - scanObjectLiteral(literal, label, scan); - } - - return { ...scan, tables, needle: needle.sort() }; -} - -const TABLE_SCAN = scanDefaultsTables(); +const TABLE_SCAN = scanDefaultsTables(REPO_ROOT); /* ------------------------------------------------------------------ */ /* The cases */ @@ -585,7 +321,7 @@ describe('the fallback only ever meets placeholders it can resolve (objectui#351 // free; only a table DISAPPEARING has to be explained. expect(TABLE_SCAN.tables.length).toBeGreaterThanOrEqual(34); expect(TABLE_SCAN.rows.length).toBeGreaterThanOrEqual(700); - expect(SOURCE_FILES.length).toBeGreaterThan(1_000); + expect(TABLE_SCAN.sourceFiles.length).toBeGreaterThan(1_000); // Every discovered table resolved to a literal and every row to a string. // A table the scanner cannot read is a hole in the gate, reported here // rather than skipped in silence. diff --git a/packages/test-support/package.json b/packages/test-support/package.json index 33324b09c8..d3d012f840 100644 --- a/packages/test-support/package.json +++ b/packages/test-support/package.json @@ -12,6 +12,7 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./defaults-table-scan": "./src/defaults-table-scan.ts", "./zod-wrapper-keys": "./src/zod-wrapper-keys.json" }, "scripts": { @@ -20,6 +21,7 @@ }, "devDependencies": { "@objectstack/spec": "^17.0.0", + "@types/node": "^26.2.0", "typescript": "^6.0.3", "zod": "^4.4.3" }, diff --git a/packages/test-support/src/defaults-table-scan.ts b/packages/test-support/src/defaults-table-scan.ts new file mode 100644 index 0000000000..6c6494cd68 --- /dev/null +++ b/packages/test-support/src/defaults-table-scan.ts @@ -0,0 +1,427 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * ONE discovery of the `createSafeTranslation` defaults tables, shared by every + * gate that judges their rows — objectui#7884. + * + * ## Why this is a shared module and not a second walk + * + * Two gates ask different questions of the SAME population: + * + * - `packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts` + * (objectui#3512) — is every placeholder spelled the one way the + * provider-less `fallbackT` can resolve? + * - `packages/app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` + * (objectui#4401, widened by objectui#7884) — does every row name a key the + * `en` pack actually defines? + * + * The second one used to read a HAND-WRITTEN list of three imported maps, so it + * judged 400 of 1056 rows (37.9%) and could not see the five dead + * `timeline.relative.*` rows of objectui#7874 at all (retired in #7887). The fix is not a second + * traversal — two traversals are two definitions of the population that drift + * apart, which is the disease objectui#7448 / #7528 / #7548 / #7825 / #7853 all + * record. So the walk objectui#3512 already had moved HERE, unchanged, and both + * gates now discover the same tables from the same code. + * + * ## Why an import-based list can never be completed by hand + * + * Not a matter of diligence: of the 32 factory call sites on this tree, **11 are + * anonymous inline object literals** passed straight to `createSafeTranslation(…)` + * and exported under no name at all. A gate that reaches its tables by + * `import { X_DEFAULT_TRANSLATIONS }` is structurally incapable of naming them. + * Discovery resolves the factory's FIRST ARGUMENT instead, so an inline table is + * gated the day it is written. + * + * ## Why it lives in `@object-ui/test-support` + * + * The two callers are in different packages (`@object-ui/i18n` and + * `@object-ui/app-shell`), and app-shell already depends on i18n — so parking + * the walk in either one would either invert a dependency or force a deep + * subpath import into another package's `src/__tests__/`, the shape + * objectui#4325 ruled out. This package is `private: true`, never published, and + * exists precisely for modules two suites share. + * + * ⚠️ It is reached as a DECLARED subpath — `@object-ui/test-support/defaults-table-scan` + * — and NOT from the package index, even though that index is otherwise the + * package's whole surface. A barrel re-export puts this module into the program + * of every consumer that imports the index for anything at all, and this module + * only type-checks where Node's ambient types are present. Measured: with it on + * the index, `data-objectstack` (which imports `{ enumOptions }` from the index + * in one test) compiled this file and failed the repo-wide type-check with three + * TS2591s. The index docstring carries the full reasoning. + * + * ## `typescript` is loaded lazily, on purpose + * + * `require('typescript')` measures 260-375ms on this container, and a barrel that + * pulled this module in would make ~40 test files wanting a DOM leak judge or a + * spec reader pay for a compiler they never use. The subpath already keeps them + * out; this keeps the cost off the two gates' own import phase as well. AGENTS.md + * §测试纪律 names exactly that shape — an unbounded module load inside a bounded + * window — as the top cause of flaky tests here (one first `import()` measured + * at 976ms against RTL's 1000ms default budget). So the compiler is pulled in on + * the first `scanDefaultsTables()` call and never at import time. The scan + * itself is memoised per repo root, so the ~1600-file walk happens once per + * process no matter how many gates ask for it. + */ + +import { createRequire } from 'node:module'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import type * as TS from 'typescript'; + +const requireFrom = createRequire(import.meta.url); +let compiler: typeof TS | null = null; + +/** The TypeScript compiler, loaded on first use. See the docstring above. */ +function ts(): typeof TS { + if (compiler === null) compiler = requireFrom('typescript') as typeof TS; + return compiler; +} + +/** The factory, and plugin-detail's re-export alias for it. */ +export const FACTORY_NAMES = new Set(['createSafeTranslation', 'createSafeTranslationHook']); + +/** + * The literal needle a hand-rolled `fallbackT` splits on, as it is spelled in + * source. objectui#3512's completeness case pins which files carry it, so a + * fourth hand-rolled copy of the interpolator forces an edit there instead of + * quietly serving an ungated table. + * + * ⚠️ ASSEMBLED FROM FRAGMENTS ON PURPOSE, and it must stay that way. This walk + * skips `__tests__` directories, which is why objectui#3512 could hold the + * needle as one literal while living in one. This module does NOT live in a + * skipped directory, so a verbatim spelling here makes the scanner match its + * own source and report itself as a fifth interpolator — measured exactly that + * way during the objectui#7884 move: `packages/test-support/src/defaults-table + * -scan.ts` appeared as a 5th needle file and turned that completeness case + * red. The runtime value is unchanged; only the spelling is. + */ +export const NEEDLE_IN_SOURCE = ['.split(', '`{{', '${'].join(''); + +/** + * The three tables whose packages re-implemented `fallbackT`'s literal needle + * rather than taking the factory. Each file states its own reason for that; + * none of them changes the grammar the needle accepts, so the rule is the same. + * `TIMELINE_DEFAULT_TRANSLATIONS` also reaches the factory — listed anyway, so + * the registry mirrors the needle-file set objectui#3512's completeness case + * pins. That deliberate double-listing is why a caller must de-duplicate on + * `where` + `key` before reporting counts: its 21 rows are discovered twice. + */ +export const HAND_ROLLED_TABLES: readonly { readonly file: string; readonly name: string }[] = [ + { file: 'packages/plugin-gantt/src/useGanttTranslation.ts', name: 'GANTT_DEFAULT_TRANSLATIONS' }, + { file: 'packages/plugin-grid/src/ImportWizard.tsx', name: 'IMPORT_DEFAULT_TRANSLATIONS' }, + { + file: 'packages/plugin-timeline/src/useTimelineTranslation.ts', + name: 'TIMELINE_DEFAULT_TRANSLATIONS', + }, +]; + +/** One row of one discovered table, located precisely enough to fix. */ +export interface DefaultsRow { + /** Human label of the owning table, including where it was discovered. */ + readonly table: string; + /** `path/to/file.ts:LINE` of the row itself. */ + readonly where: string; + readonly key: string; + readonly value: string; +} + +export interface DefaultsTableScan { + readonly rows: readonly DefaultsRow[]; + /** + * Rows whose value is not a static string, and tables that never resolved. + * NOT an exemption list: a table the scanner cannot read is a table the gate + * does not cover, and every caller is expected to assert this is empty so the + * instrument's blind-spot size can never be swallowed. + */ + readonly unreadable: readonly string[]; + /** One label per discovered table, in discovery order. */ + readonly tables: readonly string[]; + /** Files carrying the literal needle, repo-relative and sorted. */ + readonly needle: readonly string[]; + /** Every runtime source file walked — the non-vacuity floor for the walk. */ + readonly sourceFiles: readonly string[]; +} + +/** Every runtime `.ts`/`.tsx` under the workspace — tests and tooling excluded. */ +function collectSourceFiles(repoRoot: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const name = entry.name; + if ( + name === 'node_modules' || + name === 'dist' || + name === '__tests__' || + name === '__mocks__' || + name.startsWith('.') + ) { + continue; + } + const full = path.join(dir, name); + if (entry.isDirectory()) walk(full); + else if (/\.tsx?$/.test(name) && !/\.(test|spec|bench|stories)\.tsx?$/.test(name)) { + out.push(full); + } + } + }; + for (const root of ['packages', 'apps', 'examples']) { + const full = path.join(repoRoot, root); + if (existsSync(full) && statSync(full).isDirectory()) walk(full); + } + return out.sort(); +} + +/** Peel the wrappers a table declaration may carry before its object literal. */ +function unwrap(node: TS.Expression): TS.Expression { + const t = ts(); + let e = node; + for (;;) { + if (t.isAsExpression(e) || t.isSatisfiesExpression(e) || t.isParenthesizedExpression(e)) { + e = e.expression; + continue; + } + return e; + } +} + +/** + * A string a table row is declared with. Handles the `'a' + 'b'` concatenation + * one row uses (`plugin-form/src/occSave.tsx`); anything else returns + * `undefined` and is REPORTED rather than skipped — a row the gate cannot read + * is a hole in it, not an exemption. + */ +function staticString(node: TS.Expression): string | undefined { + const t = ts(); + const e = unwrap(node); + if (t.isStringLiteral(e) || t.isNoSubstitutionTemplateLiteral(e)) return e.text; + if (t.isBinaryExpression(e) && e.operatorToken.kind === t.SyntaxKind.PlusToken) { + const left = staticString(e.left); + const right = staticString(e.right); + if (left !== undefined && right !== undefined) return left + right; + } + return undefined; +} + +interface MutableScan { + rows: DefaultsRow[]; + unreadable: string[]; +} + +/** + * One walk of one repo root. Everything the scan needs is closed over here, so + * the parse cache and the source-file list cannot leak between roots. + */ +function scan(repoRoot: string): DefaultsTableScan { + const t = ts(); + const sourceFiles = collectSourceFiles(repoRoot); + const rel = (abs: string) => path.relative(repoRoot, abs); + + const parsed = new Map(); + const sourceFileFor = (abs: string): TS.SourceFile | null => { + if (parsed.has(abs)) return parsed.get(abs) ?? null; + const sf = existsSync(abs) + ? t.createSourceFile( + abs, + readFileSync(abs, 'utf8'), + t.ScriptTarget.Latest, + true, + abs.endsWith('.tsx') ? t.ScriptKind.TSX : t.ScriptKind.TS, + ) + : null; + parsed.set(abs, sf); + return sf; + }; + + /** The initializer of a top-level `const = …` in this file. */ + const constInitializer = (sf: TS.SourceFile, name: string): TS.Expression | null => { + let found: TS.Expression | null = null; + const visit = (node: TS.Node) => { + if (found) return; + if ( + t.isVariableDeclaration(node) && + t.isIdentifier(node.name) && + node.name.text === name && + node.initializer + ) { + found = node.initializer; + return; + } + t.forEachChild(node, visit); + }; + t.forEachChild(sf, visit); + return found; + }; + + /** Follow `import { } from './relative'` to the file that declares it. */ + const importedFrom = (sf: TS.SourceFile, name: string): string | null => { + let spec: string | null = null; + t.forEachChild(sf, (node) => { + if (spec !== null) return; + if ( + t.isImportDeclaration(node) && + node.importClause?.namedBindings && + t.isNamedImports(node.importClause.namedBindings) && + t.isStringLiteral(node.moduleSpecifier) + ) { + for (const element of node.importClause.namedBindings.elements) { + if (element.name.text === name) spec = node.moduleSpecifier.text; + } + } + }); + if (spec === null || !(spec as string).startsWith('.')) return null; + const base = path.resolve(path.dirname(sf.fileName), spec); + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + `${base}/index.ts`, + `${base}/index.tsx`, + ]) { + if (existsSync(candidate)) return candidate; + } + return null; + }; + + const scanObjectLiteral = ( + literal: TS.ObjectLiteralExpression, + table: string, + into: MutableScan, + keyPrefix = '', + ): void => { + const owner = literal.getSourceFile(); + const at = (node: TS.Node) => + `${rel(owner.fileName)}:${owner.getLineAndCharacterOfPosition(node.getStart()).line + 1}`; + for (const property of literal.properties) { + if (t.isPropertyAssignment(property)) { + const name = + t.isIdentifier(property.name) || t.isStringLiteral(property.name) + ? property.name.text + : null; + if (name === null) { + into.unreadable.push(`${at(property)} — computed key in ${table}`); + continue; + } + const key = keyPrefix ? `${keyPrefix}.${name}` : name; + const initializer = unwrap(property.initializer); + if (t.isObjectLiteralExpression(initializer)) { + scanObjectLiteral(initializer, table, into, key); + continue; + } + const value = staticString(initializer); + if (value === undefined) { + into.unreadable.push(`${at(property)} — ${table}.${key} is not a static string`); + continue; + } + into.rows.push({ table, where: at(property), key, value }); + } else { + into.unreadable.push(`${at(property)} — non-assignment member in ${table}`); + } + } + }; + + /** + * Resolve a `createSafeTranslation` first argument to its object literal: an + * inline table, a `const` in the same file, or a `const` imported from a + * relative module. + */ + const resolveTableArgument = ( + sf: TS.SourceFile, + argument: TS.Expression, + ): { literal: TS.ObjectLiteralExpression | null; name: string } => { + const unwrapped = unwrap(argument); + if (t.isObjectLiteralExpression(unwrapped)) { + return { literal: unwrapped, name: '(inline table)' }; + } + if (t.isIdentifier(unwrapped)) { + const name = unwrapped.text; + let initializer = constInitializer(sf, name); + if (initializer === null) { + const from = importedFrom(sf, name); + const imported = from === null ? null : sourceFileFor(from); + if (imported) initializer = constInitializer(imported, name); + } + if (initializer !== null) { + const literal = unwrap(initializer); + if (t.isObjectLiteralExpression(literal)) return { literal, name }; + } + return { literal: null, name }; + } + return { literal: null, name: t.SyntaxKind[unwrapped.kind] }; + }; + + const out: MutableScan = { rows: [], unreadable: [] }; + const tables: string[] = []; + const needle: string[] = []; + + for (const abs of sourceFiles) { + const text = readFileSync(abs, 'utf8'); + if (text.includes(NEEDLE_IN_SOURCE)) needle.push(rel(abs)); + if (!text.includes('createSafeTranslation')) continue; + const sf = sourceFileFor(abs); + if (!sf) continue; + const visit = (node: TS.Node) => { + if (t.isCallExpression(node)) { + const callee = node.expression; + const name = t.isIdentifier(callee) + ? callee.text + : t.isPropertyAccessExpression(callee) + ? callee.name.text + : null; + if (name !== null && FACTORY_NAMES.has(name) && node.arguments.length > 0) { + const line = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1; + const { literal, name: tableName } = resolveTableArgument(sf, node.arguments[0]); + const label = `${tableName} (${rel(abs)}:${line})`; + if (literal === null) { + // Not an exemption: a table the gate cannot reach is a table the + // gate does not cover, and that has to be visible. + out.unreadable.push(`${rel(abs)}:${line} — cannot resolve ${tableName} to a table`); + } else { + tables.push(label); + scanObjectLiteral(literal, label, out); + } + } + } + t.forEachChild(node, visit); + }; + t.forEachChild(sf, visit); + } + + for (const { file, name } of HAND_ROLLED_TABLES) { + const abs = path.join(repoRoot, file); + const sf = sourceFileFor(abs); + const initializer = sf === null ? null : constInitializer(sf, name); + const literal = initializer === null ? null : unwrap(initializer); + if (literal === null || !t.isObjectLiteralExpression(literal)) { + out.unreadable.push(`${file} — hand-rolled table ${name} no longer resolves`); + continue; + } + const label = `${name} (${file})`; + tables.push(label); + scanObjectLiteral(literal, label, out); + } + + return { ...out, tables, needle: needle.sort(), sourceFiles }; +} + +const cache = new Map(); + +/** + * Discover every `createSafeTranslation` defaults table under `repoRoot`, plus + * the three hand-rolled siblings, and read their rows from source. + * + * Memoised per root: the walk parses ~1600 files, and both gates in one process + * should pay for it once. + */ +export function scanDefaultsTables(repoRoot: string): DefaultsTableScan { + const cached = cache.get(repoRoot); + if (cached) return cached; + const fresh = scan(repoRoot); + cache.set(repoRoot, fresh); + return fresh; +} diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts index 8ec64042e0..c8bfe8a0b0 100644 --- a/packages/test-support/src/index.ts +++ b/packages/test-support/src/index.ts @@ -60,3 +60,30 @@ export { arrayElementSchema } from './spec-array-element'; * touching either side. */ export { ZOD_WRAPPER_KEYS } from './zod-wrapper-keys'; + +/** + * ⛔ `defaults-table-scan.ts` is deliberately NOT re-exported here. It is reached + * as `@object-ui/test-support/defaults-table-scan`, a DECLARED subpath in this + * package's `exports` map — the same escape hatch `./zod-wrapper-keys` uses for + * the case the index cannot serve. + * + * The index rule above still stands, and this is not a hole in it: it is the one + * shape the index physically cannot carry. That module reads the workspace from + * disk (`node:fs`, `node:path`, `node:module`), so it only type-checks in a + * program that has Node's ambient types — and a barrel re-export puts a module + * into the program of EVERY consumer that imports the barrel for anything at all. + * + * Measured, not predicted (objectui#7884, PR objectui#7902): with the re-export + * here, `tsc -p packages/data-objectstack/tsconfig.json --listFiles` pulled all + * nine of this package's modules into that package's program, because one of its + * tests imports `{ enumOptions }` from this index. `data-objectstack` has no + * `node` types, so the repo-wide `pnpm turbo run type-check` failed with three + * TS2591s in a file that package never asked for. The subpath keeps the Node-only + * module out of every program that does not name it, which is the property a + * shared test module owes its consumers: it adds no obligation to anyone. + * + * `objectui#4325`'s lesson is untouched — the hazard there was an UNDECLARED deep + * path that resolved only through the vitest alias and was TS2882 for `tsc`. This + * one is declared in `exports`, so `tsc` (moduleResolution `bundler`) resolves it + * exactly as it resolves `.`. + */ diff --git a/packages/test-support/tsconfig.json b/packages/test-support/tsconfig.json index 7aa277391a..0c0683f53d 100644 --- a/packages/test-support/tsconfig.json +++ b/packages/test-support/tsconfig.json @@ -13,7 +13,13 @@ // test files. "extends": "../../tsconfig.json", "compilerOptions": { - "jsx": "react-jsx" + "jsx": "react-jsx", + // `defaults-table-scan.ts` reads the workspace from disk (`node:fs`, + // `node:path`, `node:module`), so this project needs Node's ambient types. + // Naming them here REPLACES the auto-included set, which is why `vitest` is + // listed too: `dom-leak-judge.test.tsx` and the other suites in this project + // resolve their globals through it. + "types": ["node", "vitest/globals"] }, "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df8fbcd77e..f0b19c5535 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1368,6 +1368,9 @@ importers: specifier: ^17.0.11 version: 17.0.11(i18next@26.4.0(typescript@6.0.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) devDependencies: + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2881,6 +2884,9 @@ importers: '@objectstack/spec': specifier: ^17.0.0 version: 17.2.0(ai@7.0.65(zod@4.4.3)) + '@types/node': + specifier: ^26.2.0 + version: 26.2.0 typescript: specifier: ^6.0.3 version: 6.0.3