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