diff --git a/plugins/__tests__/lexiconLeafImports.test.ts b/plugins/__tests__/lexiconLeafImports.test.ts deleted file mode 100644 index d2328d5af2..0000000000 --- a/plugins/__tests__/lexiconLeafImports.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Differential resolution test for babel-plugin-lexicon-leaf-imports. - * - * For every project file that imports the lexicon barrel, this test computes - * where each member chain (`app.bsky.feed.like`) SHOULD lead by walking the - * barrels' actual `export * as` statements (an oracle independent of the - * plugin's filesystem heuristic), then runs the real transform and resolves - * the leaf imports it emitted. The two resolutions must agree - compared by - * file content hash, so the assertion is "the import binds to the same code" - * rather than a path-string comparison. - * - * Chains the oracle cannot follow statically (computed access, namespace used - * as a value) must bail in the plugin too: the barrel import has to survive in - * the output exactly when the oracle predicts a bail. - * - * The mechanical layer (specifier resolution, barrel parsing) is shared with - * the plugin via lexiconBarrels.js so the copies cannot drift; the oracle's - * independence lies in walking the export graph itself instead of trusting - * the plugin's filesystem-name heuristic. - */ -import crypto from 'node:crypto' -import fs from 'node:fs' -import path from 'node:path' - -import * as babel from '@babel/core' -import {parse} from '@babel/parser' - -import { - barrelExports, - resolveModuleFile, - SDK_BARREL_RE, -} from '../lexiconBarrels' - -// eslint-disable-next-line @typescript-eslint/no-require-imports -const traverse = require('@babel/traverse').default - -const ROOT = path.resolve(__dirname, '../..') -const PLUGIN = path.join(ROOT, 'plugins/babel-plugin-lexicon-leaf-imports.js') -const APP_BARREL_ENTRY = path.join(ROOT, 'src/lexicons/index.ts') -const SDK_DIST = path.join(ROOT, 'node_modules/@bsky/sdk/dist') - -function listFiles(dir: string, exts: string[]): string[] { - const out: string[] = [] - for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { - const full = path.join(dir, entry.name) - if (entry.isDirectory()) { - out.push(...listFiles(full, exts)) - } else if (exts.some(ext => entry.name.endsWith(ext))) { - out.push(full) - } - } - return out -} - -const hashCache = new Map() -function contentHash(file: string): string { - let h = hashCache.get(file) - if (!h) { - h = crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex') - hashCache.set(file, h) - } - return h -} - -type ChainResult = - {leaf: string; chain: string[]} | {bail: true} | {error: string} - -/** - * Follow one member chain through the barrels' export statements: at each - * step look the segment up in the current barrel's `export * as` map and - * resolve its source. A resolved file that is not itself a pure-namespace - * barrel is the leaf. Needing another segment when the expression has none - * left (or has a non-static one) is a bail, mirroring the plugin's contract. - */ -function walkChain( - ref: any, - entryBarrel: string, - rootSegment: string, -): ChainResult { - let curFile = entryBarrel - let segment = rootSegment - let cur = ref - const chain = [rootSegment] - for (;;) { - const exports = barrelExports(curFile) - if (!exports) return {error: `${curFile} is not a pure namespace barrel`} - const spec = exports.get(segment) - if (!spec) return {error: `'${chain.join('.')}' not exported by ${curFile}`} - const next = resolveModuleFile(curFile, spec) - if (!next) return {error: `cannot resolve '${spec}' from ${curFile}`} - if (barrelExports(next) === null) return {leaf: next, chain} - const parent = cur.parentPath - if ( - !parent?.isMemberExpression() || - parent.node.object !== cur.node || - parent.node.computed || - parent.node.property.type !== 'Identifier' - ) { - return {bail: true} - } - curFile = next - segment = parent.node.property.name - chain.push(segment) - cur = parent - } -} - -/** - * References in type positions are stripped before the plugin runs, so the - * oracle must ignore them too. In type positions chains appear as - * TSQualifiedName (or a bare TSTypeQuery for `typeof app`), never as - * MemberExpression. - */ -function isTypeReference(ref: any): boolean { - return ref.parentPath?.isTSQualifiedName() || ref.parentPath?.isTSTypeQuery() -} - -type Expectation = { - /** chain joined with '.' -> absolute leaf file */ - leaves: Map - /** true when the plugin must keep (part of) the barrel import */ - barrelRetained: boolean - errors: string[] -} - -function computeExpectation(file: string, code: string): Expectation { - const isSdk = file.startsWith(SDK_DIST) - const leaves = new Map() - const errors: string[] = [] - let barrelRetained = false - - const ast = parse(code, { - sourceType: 'module', - plugins: ['typescript', 'jsx'], - }) - traverse(ast, { - Program(programPath: any) { - for (const stmt of programPath.get('body')) { - if (!stmt.isImportDeclaration()) continue - if (stmt.node.importKind === 'type') continue - const source = stmt.node.source.value - let entryBarrel: string | null = null - if (!isSdk && source === '#/lexicons') { - entryBarrel = APP_BARREL_ENTRY - } else if (isSdk && SDK_BARREL_RE.test(source)) { - entryBarrel = resolveModuleFile(file, source) - } - if (!entryBarrel) continue - - for (const spec of stmt.get('specifiers')) { - /* - * Type-only specifiers are stripped from the output together with - * their references, so they neither rewrite nor retain the barrel. - * Non-named value specifiers (namespace/default) are kept by the - * plugin and do retain it. - */ - if (spec.node.importKind === 'type') continue - if (!spec.isImportSpecifier()) { - barrelRetained = true - continue - } - const imported = spec.node.imported - const rootSegment = - imported.type === 'Identifier' ? imported.name : imported.value - const binding = programPath.scope.getBinding(spec.node.local.name) - if (!binding) continue - const chains: Array<{leaf: string; chain: string[]}> = [] - let bailed = false - for (const ref of binding.referencePaths) { - if (isTypeReference(ref)) continue - const r = walkChain(ref, entryBarrel, rootSegment) - if ('error' in r) { - errors.push(r.error) - bailed = true - break - } - if ('bail' in r) { - bailed = true - break - } - chains.push(r) - } - if (bailed) { - barrelRetained = true - } else { - for (const {leaf, chain} of chains) { - leaves.set(chain.join('.'), leaf) - } - } - } - } - programPath.stop() - }, - }) - return {leaves, barrelRetained, errors} -} - -/** - * The real transform, reduced to the plugins that participate in import - * rewriting. react-compiler, lingui, and worklets are omitted for speed; they - * do not touch import declarations, and their interaction with this plugin is - * covered by the full Jest suite running the complete config. - */ -function transformActual(file: string, code: string): string { - const result = babel.transformSync(code, { - filename: file, - cwd: ROOT, - configFile: false, - babelrc: false, - presets: [ - [ - require.resolve('@babel/preset-typescript'), - {isTSX: /\.tsx$/.test(file), allExtensions: true}, - ], - ], - plugins: [ - [ - require.resolve('babel-plugin-module-resolver'), - {alias: {'#': './src'}}, - ], - [PLUGIN, {roots: [path.join(ROOT, 'src/lexicons')]}], - ], - }) - return result!.code! -} - -type Actual = { - /** absolute resolved leaf files from emitted `_lex_*` namespace imports */ - leaves: Map - barrelRetained: boolean -} - -function collectActual(file: string, output: string): Actual { - const isSdk = file.startsWith(SDK_DIST) - const leaves = new Map() - let barrelRetained = false - const ast = parse(output, {sourceType: 'module', plugins: ['jsx']}) - for (const stmt of ast.program.body) { - if (stmt.type !== 'ImportDeclaration') continue - const source = stmt.source.value - const ns = stmt.specifiers.find( - s => s.type === 'ImportNamespaceSpecifier' && /^_lex_/.test(s.local.name), - ) - if (ns) { - const resolved = resolveModuleFile(file, source) - leaves.set(source, resolved ?? ``) - continue - } - const resolved = source.startsWith('.') ? resolveModuleFile(file, source) : null - if ( - resolved === APP_BARREL_ENTRY || - (isSdk && SDK_BARREL_RE.test(source) && resolved) - ) { - barrelRetained = true - } - } - return {leaves, barrelRetained} -} - -function checkFile(file: string): string[] { - const failures: string[] = [] - const rel = path.relative(ROOT, file) - const code = fs.readFileSync(file, 'utf8') - - const expected = computeExpectation(file, code) - for (const err of expected.errors) { - failures.push(`${rel}: oracle error: ${err}`) - } - - let output: string - try { - output = transformActual(file, code) - } catch (e) { - failures.push(`${rel}: transform threw: ${(e as Error).message}`) - return failures - } - const actual = collectActual(file, output) - - const actualByHash = new Map() - for (const [source, resolved] of actual.leaves) { - if (resolved.startsWith('<')) { - failures.push(`${rel}: emitted import does not resolve: '${source}'`) - } else { - actualByHash.set(contentHash(resolved), resolved) - } - } - - const expectedHashes = new Set() - for (const [chain, leaf] of expected.leaves) { - const hash = contentHash(leaf) - expectedHashes.add(hash) - if (!actualByHash.has(hash)) { - failures.push( - `${rel}: chain '${chain}' should bind to ${path.relative(ROOT, leaf)} ` + - `but no emitted import matches its content`, - ) - } - } - for (const [hash, resolved] of actualByHash) { - if (!expectedHashes.has(hash)) { - failures.push( - `${rel}: emitted import of ${path.relative(ROOT, resolved)} ` + - `matches no barrel chain in the source`, - ) - } - } - - if (actual.barrelRetained !== expected.barrelRetained) { - failures.push( - `${rel}: barrel import ${actual.barrelRetained ? 'survived' : 'removed'} ` + - `but oracle expected ${expected.barrelRetained ? 'a bail' : 'full rewrite'}`, - ) - } - return failures -} - -describe('lexicon leaf import rewrites resolve to the same modules as the barrels', () => { - test('app sources', () => { - const consumers = listFiles(path.join(ROOT, 'src'), ['.ts', '.tsx']).filter( - f => fs.readFileSync(f, 'utf8').includes(`from '#/lexicons'`), - ) - expect(consumers.length).toBeGreaterThan(100) - const failures = consumers.flatMap(checkFile) - expect(failures).toEqual([]) - }, 240_000) - - test('@bsky/sdk compiled output', () => { - const consumers = listFiles(SDK_DIST, ['.js']).filter(f => - fs.readFileSync(f, 'utf8').includes(`lexicons/index.js'`), - ) - expect(consumers.length).toBeGreaterThan(0) - const failures = consumers.flatMap(checkFile) - expect(failures).toEqual([]) - }, 120_000) -}) diff --git a/plugins/__tests__/lexiconRewriteIdentity.test.ts b/plugins/__tests__/lexiconRewriteIdentity.test.ts new file mode 100644 index 0000000000..0aa51ebc85 --- /dev/null +++ b/plugins/__tests__/lexiconRewriteIdentity.test.ts @@ -0,0 +1,108 @@ +/* + * Differential runtime test for babel-plugin-lexicon-leaf-imports. + * + * The plugin is run with `collectRewrites: true`, which makes it report every + * member-chain rewrite it performed on the Babel file metadata as + * `chain -> emitted specifier` (e.g. `'app.bsky.feed.like' -> + * '../lexicons/app/bsky/feed/like'`). For each reported rewrite the barrel + * itself is the ground truth: walking the chain's segments over + * `require('#/lexicons')` must yield the very module the emitted specifier + * resolves to. `export * as` re-exports the target's module namespace object, + * so the comparison is `===` through Jest's own resolver on both sides - if + * the plugin rewired a chain to the wrong module, identity breaks. + * + * Scoped to app sources: the SDK's ESM dist does not load through Jest's CJS + * pipeline, so its rewrites cannot be required here. + */ +import fs from 'node:fs' +import path from 'node:path' + +import * as babel from '@babel/core' + +const ROOT = path.resolve(__dirname, '../..') +const PLUGIN = path.join(ROOT, 'plugins/babel-plugin-lexicon-leaf-imports.js') + +function listFiles(dir: string, exts: string[]): string[] { + const out: string[] = [] + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + out.push(...listFiles(full, exts)) + } else if (exts.some(ext => entry.name.endsWith(ext))) { + out.push(full) + } + } + return out +} + +/** + * The real transform, reduced to the plugins that participate in import + * rewriting. react-compiler, lingui, and worklets are omitted for speed; they + * do not touch import declarations, and their interaction with this plugin is + * covered by the full Jest suite running the complete config. Returns the + * rewrite map the plugin collected for this file. + */ +function collectRewrites(file: string): Record { + const code = fs.readFileSync(file, 'utf8') + const result = babel.transformSync(code, { + filename: file, + cwd: ROOT, + configFile: false, + babelrc: false, + presets: [ + [ + require.resolve('@babel/preset-typescript'), + {isTSX: /\.tsx$/.test(file), allExtensions: true}, + ], + ], + plugins: [ + [ + require.resolve('babel-plugin-module-resolver'), + {alias: {'#': './src'}}, + ], + [ + PLUGIN, + {roots: [path.join(ROOT, 'src/lexicons')], collectRewrites: true}, + ], + ], + }) + return (result!.metadata as any)?.lexiconLeafImports ?? {} +} + +describe('lexicon leaf import rewrites', () => { + test('every rewritten chain resolves to the same module as the barrel', () => { + const consumers = listFiles(path.join(ROOT, 'src'), ['.ts', '.tsx']).filter( + f => fs.readFileSync(f, 'utf8').includes(`from '#/lexicons'`), + ) + expect(consumers.length).toBeGreaterThan(100) + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const lexicons = require('#/lexicons') + const failures: string[] = [] + let rewrites = 0 + for (const file of consumers) { + for (const [chain, specifier] of Object.entries(collectRewrites(file))) { + rewrites++ + const viaBarrel = chain + .split('.') + .reduce((o: any, k) => o?.[k], lexicons) + /* + * The emitted specifier is extension-less, so Jest resolves the leaf + * the same way it resolves the barrel's own internal re-exports + * (platform extensions included). + */ + // eslint-disable-next-line @typescript-eslint/no-require-imports + const direct = require(path.resolve(path.dirname(file), specifier)) + if (viaBarrel !== direct) { + failures.push( + `${path.relative(ROOT, file)}: '${chain}' was rewritten to ` + + `'${specifier}', which is not the module at lexicons.${chain}`, + ) + } + } + } + /* A sudden collapse means the plugin stopped rewriting anything. */ + expect(rewrites).toBeGreaterThan(100) + expect(failures).toEqual([]) + }, 240_000) +}) diff --git a/plugins/babel-plugin-lexicon-leaf-imports.js b/plugins/babel-plugin-lexicon-leaf-imports.js index 5725d5c462..bd7e732231 100644 --- a/plugins/babel-plugin-lexicon-leaf-imports.js +++ b/plugins/babel-plugin-lexicon-leaf-imports.js @@ -201,6 +201,12 @@ module.exports = function lexiconLeafImports(babel, options = {}) { } } const debug = !!process.env.BSKY_LEXICON_IMPORTS_DEBUG + /* + * Test hook: when set, every performed rewrite is reported on the Babel file + * metadata as `metadata.lexiconLeafImports[chain] = emitted specifier`, so a + * caller of transformSync can compare each rewrite against the real barrel. + */ + const collectRewrites = !!options.collectRewrites const stats = {files: 0, rewrites: 0, bails: 0} function barrelDirFor(source, filename) { @@ -367,7 +373,11 @@ module.exports = function lexiconLeafImports(babel, options = {}) { keep.push(spec.node) continue } - for (const {memberPath, leafFile} of plan) { + for (const {memberPath, leafFile, segments} of plan) { + if (collectRewrites) { + const map = (state.file.metadata.lexiconLeafImports ??= {}) + map[segments.join('.')] = toSpecifier(filename, leafFile) + } memberPath.replaceWith(t.cloneNode(namespaceIdFor(leafFile))) stats.rewrites++ }