diff --git a/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js b/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js new file mode 100644 index 0000000000..01ba27d442 --- /dev/null +++ b/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js @@ -0,0 +1,539 @@ +/* + * Tests for babel-plugin-lexicon-leaf-imports, in three layers: + * + * 1. Transform unit tests: run the plugin alone over small snippets against + * the real src/lexicons tree and assert the rewrite / bail behavior. + * + * 2. App-source proof: enumerate every chain reachable through the barrels' + * own `export * as` graph (the only chains user code can write), generate + * a probe file referencing all of them, transform it, and typecheck the + * output with the app tsconfig. This proves every rewritten specifier + * resolves to a real module. The transform itself also exercises the + * plugin's verifyChain proof for every single chain. + * + * 3. SDK dist proof: the plugin also rewrites @bsky/sdk's compiled output + * (plain JS). Transform every dist file that imports the lexicon barrel + * and typecheck the result with checkJs, where imports resolve to the + * SDK's shipped .d.ts files - so unresolvable specifiers (TS2307) and + * missing members on a rewritten leaf namespace (TS2339) both surface. + * tsc never checks JS under node_modules, so transformed files are + * written to a temp mirror and resolved back into the real dist via + * rootDirs. checkJs has inherent noise on compiled output, so the shadow + * diagnostics are compared against a baseline run of the untransformed + * files: only diagnostics introduced by the plugin fail the test. + */ +const {transformSync} = require('@babel/core') +const {parse} = require('@babel/parser') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const ts = require('typescript') + +const plugin = require('../babel-plugin-lexicon-leaf-imports') + +const ROOT = path.resolve(__dirname, '../..') +const LEXICONS_ROOT = path.join(ROOT, 'src', 'lexicons') +const SDK_DIST = path.join(ROOT, 'node_modules', '@bsky', 'sdk', 'dist') + +const TYPECHECK_TIMEOUT_MS = 240_000 + +function applyPlugin(code, filename) { + return transformSync(code, { + filename, + configFile: false, + babelrc: false, + parserOpts: {plugins: ['typescript']}, + plugins: [[plugin, {roots: [LEXICONS_ROOT]}]], + }).code +} + +/** A virtual .ts path inside src/ - the file itself never exists on disk. */ +const PROBE_FILE = path.join(ROOT, 'src', '__lexicon_leaf_probe__.ts') + +describe('transform', () => { + test('rewrites a barrel member chain to a leaf namespace import', () => { + const out = applyPlugin( + `import {app} from './lexicons'\nvoid app.bsky.feed.like\n`, + PROBE_FILE, + ) + expect(out).toContain( + `import * as _lex_app_bsky_feed_like from "./lexicons/app/bsky/feed/like"`, + ) + expect(out).toContain('void _lex_app_bsky_feed_like') + expect(out).not.toContain(`from './lexicons'`) + }) + + test('bails when the namespace is used as a value', () => { + const src = `import {app} from './lexicons'\nconsole.log(app)\n` + const out = applyPlugin(src, PROBE_FILE) + expect(out).toContain(`from './lexicons'`) + expect(out).not.toContain('import *') + }) + + test('bails when the chain stops at a non-leaf barrel', () => { + const src = `import {app} from './lexicons'\nvoid app.bsky\n` + const out = applyPlugin(src, PROBE_FILE) + expect(out).toContain(`from './lexicons'`) + expect(out).not.toContain('import *') + }) + + test('leaves type-only imports untouched', () => { + const src = `import type {app} from './lexicons'\nexport type T = typeof app\n` + const out = applyPlugin(src, PROBE_FILE) + expect(out).toContain(`from './lexicons'`) + expect(out).not.toContain('import *') + }) + + test('ignores imports that are not lexicon barrels', () => { + const src = `import {app} from './other'\nvoid app.bsky.feed.like\n` + const out = applyPlugin(src, PROBE_FILE) + expect(out).toContain(`from './other'`) + expect(out).toContain('void app.bsky.feed.like') + expect(out).not.toContain('import *') + }) +}) + +/** + * Enumerate every leaf chain by following `export * as from '...'` + * through the barrel graph, mirroring the plugin's own leaf/barrel rule: a + * target with a sibling directory is a barrel to recurse into, otherwise a + * leaf. Chains come out as e.g. ['app', 'bsky', 'feed', 'like']. + */ +function collectLeafChains(rootDir) { + const chains = [] + + function resolveTarget(fromFile, spec) { + const abs = path.resolve(path.dirname(fromFile), spec) + if (/\.(ts|js)$/.test(abs) && fs.existsSync(abs)) return abs + for (const ext of ['.ts', '.js']) { + if (fs.existsSync(abs + ext)) return abs + ext + } + throw new Error(`cannot resolve '${spec}' from ${fromFile}`) + } + + function walk(barrelFile, segments) { + const ast = parse(fs.readFileSync(barrelFile, 'utf8'), { + sourceType: 'module', + plugins: ['typescript'], + }) + for (const stmt of ast.program.body) { + if (stmt.type !== 'ExportNamedDeclaration' || !stmt.source) continue + if (stmt.exportKind === 'type') continue + for (const spec of stmt.specifiers) { + if (spec.type !== 'ExportNamespaceSpecifier') continue + const name = + spec.exported.type === 'Identifier' + ? spec.exported.name + : spec.exported.value + const target = resolveTarget(barrelFile, stmt.source.value) + const asDir = target.replace(/\.(ts|js)$/, '') + if (fs.existsSync(asDir) && fs.statSync(asDir).isDirectory()) { + walk(target, [...segments, name]) + } else { + chains.push([...segments, name]) + } + } + } + } + + walk(resolveTarget(path.join(rootDir, 'index.ts'), './index'), []) + return chains +} + +function formatDiagnostics(diags) { + return diags + .slice(0, 20) + .map(d => { + const msg = ts.flattenDiagnosticMessageText(d.messageText, ' ') + if (d.file && d.start !== undefined) { + const {line} = d.file.getLineAndCharacterOfPosition(d.start) + return `${d.file.fileName}:${line + 1} TS${d.code}: ${msg}` + } + return `TS${d.code}: ${msg}` + }) + .join('\n') +} + +function fileDiagnostics(program, sourceFile) { + return [ + ...program.getSyntacticDiagnostics(sourceFile), + ...program.getSemanticDiagnostics(sourceFile), + ].filter(d => d.category === ts.DiagnosticCategory.Error) +} + +function loadAppCompilerOptions() { + const configPath = path.join(ROOT, 'tsconfig.json') + const config = ts.readConfigFile(configPath, ts.sys.readFile).config + const parsed = ts.parseJsonConfigFileContent( + {...config, include: [], files: []}, + ts.sys, + ROOT, + undefined, + configPath, + ) + return {...parsed.options, noEmit: true, skipLibCheck: true} +} + +/** A CompilerHost serving in-memory content for the paths in `overlays`. */ +function createOverlayHost(options, overlays) { + const host = ts.createCompilerHost(options) + const origGetSourceFile = host.getSourceFile.bind(host) + const origFileExists = host.fileExists.bind(host) + const origReadFile = host.readFile.bind(host) + host.fileExists = f => overlays.has(f) || origFileExists(f) + host.readFile = f => overlays.get(f) ?? origReadFile(f) + host.getSourceFile = (f, lang, ...rest) => + overlays.has(f) + ? ts.createSourceFile(f, overlays.get(f), lang) + : origGetSourceFile(f, lang, ...rest) + return host +} + +describe('app sources: every barrel chain rewrites and typechecks', () => { + test( + 'probe referencing all leaf chains', + () => { + const chains = collectLeafChains(LEXICONS_ROOT) + /* The tree is large; a sudden collapse means the walker broke. */ + expect(chains.length).toBeGreaterThan(100) + + const barrelRoots = [...new Set(chains.map(c => c[0]))] + const probeSource = + `import {${barrelRoots.join(', ')}} from './lexicons'\n` + + chains.map(c => `void ${c.join('.')}`).join('\n') + + '\n' + + /* + * This also runs the plugin's verifyChain proof for every chain: any + * filesystem/barrel divergence throws here. + */ + const out = applyPlugin(probeSource, PROBE_FILE) + + expect(out).not.toContain(`from './lexicons'`) + const leafImports = out.match(/from "\.\/lexicons\//g) ?? [] + expect(leafImports).toHaveLength(chains.length) + + /* + * Typecheck the transformed probe with the app tsconfig. The probe is + * overlaid at a virtual path inside src/ so its relative leaf imports + * resolve against the real tree. + */ + const options = loadAppCompilerOptions() + const host = createOverlayHost(options, new Map([[PROBE_FILE, out]])) + const program = ts.createProgram([PROBE_FILE], options, host) + const probeSf = program.getSourceFile(PROBE_FILE) + expect(probeSf).toBeDefined() + const errors = fileDiagnostics(program, probeSf) + if (errors.length > 0) { + throw new Error( + `transformed probe has type errors:\n${formatDiagnostics(errors)}`, + ) + } + + /* + * Canary: prove this program setup actually flags a bad specifier, so + * a broken overlay host cannot produce a vacuous pass. + */ + const canary = + out + `import * as _bad from './lexicons/app/bsky/feed/__nope__'\n` + const canaryHost = createOverlayHost( + options, + new Map([[PROBE_FILE, canary]]), + ) + const canaryProgram = ts.createProgram([PROBE_FILE], options, canaryHost) + const canaryDiags = fileDiagnostics( + canaryProgram, + canaryProgram.getSourceFile(PROBE_FILE), + ) + expect(canaryDiags.map(d => d.code)).toContain(2307) + }, + TYPECHECK_TIMEOUT_MS, + ) +}) + +describe('@bsky/sdk dist: rewrites typecheck against shipped .d.ts', () => { + let tmpDir + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, {recursive: true, force: true}) + }) + + function findBarrelImporters(dir) { + const found = [] + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + found.push(...findBarrelImporters(full)) + } else if ( + entry.name.endsWith('.js') && + /^import[^\n]*['"][^'"]*lexicons\/index\.js['"]/m.test( + fs.readFileSync(full, 'utf8'), + ) + ) { + found.push(full) + } + } + return found + } + + test( + 'transformed dist files introduce no new diagnostics', + () => { + const importers = findBarrelImporters(SDK_DIST) + expect(importers.length).toBeGreaterThan(0) + + const transformed = [] + for (const file of importers) { + const code = fs.readFileSync(file, 'utf8') + /* Real filename so the plugin's @bsky/sdk path detection triggers. */ + const out = applyPlugin(code, file) + if (out.trim() !== code.trim()) { + transformed.push({file, original: code, shadow: out}) + } + } + expect(transformed.length).toBeGreaterThan(0) + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lexicon-leafcheck-')) + const mirrors = { + shadow: path.join(tmpDir, 'shadow'), + baseline: path.join(tmpDir, 'baseline'), + } + + for (const kind of ['shadow', 'baseline']) { + fs.mkdirSync(mirrors[kind], {recursive: true}) + fs.writeFileSync( + path.join(mirrors[kind], 'package.json'), + '{"type": "module"}\n', + ) + } + const mirrorPath = (kind, file) => + path.join(mirrors[kind], 'dist', path.relative(SDK_DIST, file)) + for (const {file, original, shadow} of transformed) { + for (const [kind, code] of [ + ['shadow', shadow], + ['baseline', original], + ]) { + const dest = mirrorPath(kind, file) + fs.mkdirSync(path.dirname(dest), {recursive: true}) + fs.writeFileSync(dest, code) + } + } + + function diagnose(kind) { + const options = { + allowJs: true, + checkJs: true, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + /* + * Relative imports in the mirror (both untouched ones like + * './api.js' and the plugin's '../lexicons/...' rewrites) resolve + * into the real dist, landing on its .d.ts files. + */ + rootDirs: [path.join(mirrors[kind], 'dist'), SDK_DIST], + } + const roots = transformed.map(t => mirrorPath(kind, t.file)) + const program = ts.createProgram(roots, options) + const byFile = new Map() + for (const t of transformed) { + const sf = program.getSourceFile(mirrorPath(kind, t.file)) + if (!sf) { + throw new Error(`${mirrorPath(kind, t.file)} missing from program`) + } + byFile.set(t.file, fileDiagnostics(program, sf)) + } + return byFile + } + + /* + * Canary: prove the checkJs machinery actually checks members through + * the SDK's .d.ts files. If this setup ever degrades to not-checking, + * a real regression would pass silently - so require a known-bad + * member access to be flagged. + */ + { + const canary = path.join(mirrors.shadow, 'dist', '__canary__.js') + fs.writeFileSync( + canary, + `import * as leaf from './lexicons/index.js'\nvoid leaf.__does_not_exist__\n`, + ) + const options = { + allowJs: true, + checkJs: true, + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + rootDirs: [path.join(mirrors.shadow, 'dist'), SDK_DIST], + } + const program = ts.createProgram([canary], options) + const diags = fileDiagnostics(program, program.getSourceFile(canary)) + expect(diags.map(d => d.code)).toContain(2339) + fs.rmSync(canary) + } + + const baseline = diagnose('baseline') + const shadow = diagnose('shadow') + + const diagKey = d => + `TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}` + const regressions = [] + for (const {file} of transformed) { + const known = new Set(baseline.get(file).map(diagKey)) + for (const d of shadow.get(file)) { + if (!known.has(diagKey(d))) regressions.push(d) + } + } + if (regressions.length > 0) { + throw new Error( + `plugin introduced diagnostics in @bsky/sdk dist:\n${formatDiagnostics(regressions)}`, + ) + } + }, + TYPECHECK_TIMEOUT_MS, + ) +}) + +describe('app callsites: transformed sources typecheck', () => { + /* + * The real-usage complement to the probe: transform every app file that + * imports the barrel - with babel-plugin-module-resolver ahead of the + * plugin, as in babel.config.js, so the '#/lexicons' -> relative-path + * interop and ordering are exercised - and typecheck the transformed files + * in place of the originals. + * + * Types are kept (no preset-typescript) so tsc has something to check. + * One behavioral difference follows: in the real pipeline type-only + * references are stripped before the plugin's Program exit, so removing a + * fully-rewritten specifier is always safe there. Here type positions + * survive, and Babel's scope does not count them as references - so when + * the plugin drops a specifier the type positions still need it. Those + * names are re-added as a type-only barrel import, which is exactly their + * production status: erased at runtime, checked against the barrel. + * + * checked-vs-baseline: diagnostics of each transformed file are compared + * against the same file run through the identical parse/print pipeline + * WITHOUT the leaf plugin. Reprinting artifacts (e.g. a reflowed + * ts-expect-error directive missing its line) then affect both sides + * equally and diff out - only differences the plugin caused can fail. + */ + test( + 'every file importing the barrel', + () => { + const consumers = [] + ;(function walk(dir) { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if ( + /\.tsx?$/.test(entry.name) && + !entry.name.endsWith('.d.ts') && + fs.readFileSync(full, 'utf8').includes(`from '#/lexicons'`) + ) { + consumers.push(full) + } + } + })(path.join(ROOT, 'src')) + expect(consumers.length).toBeGreaterThan(100) + + /** Names bound by value imports of the barrel, keyed off any path form. */ + function barrelImportNames(code, file) { + const names = new Set() + const ast = parse(code, { + sourceType: 'module', + plugins: ['typescript', 'jsx'], + }) + for (const stmt of ast.program.body) { + if (stmt.type !== 'ImportDeclaration') continue + const source = stmt.source.value + const abs = source.startsWith('.') + ? path + .resolve(path.dirname(file), source) + .replace(/[\\/]index$/, '') + : null + if (source !== '#/lexicons' && abs !== LEXICONS_ROOT) continue + for (const spec of stmt.specifiers) { + if (spec.type === 'ImportSpecifier') names.add(spec.local.name) + } + } + return names + } + + function transformConsumer(code, file, withPlugin) { + let out = transformSync(code, { + filename: file, + cwd: ROOT, + configFile: false, + babelrc: false, + parserOpts: {plugins: ['typescript', 'jsx']}, + plugins: [ + [ + require.resolve('babel-plugin-module-resolver'), + {alias: {'#': './src', crypto: './src/platform/crypto.ts'}}, + ], + ...(withPlugin ? [[plugin, {roots: [LEXICONS_ROOT]}]] : []), + ], + }).code + const dropped = [...barrelImportNames(code, file)].filter( + n => !barrelImportNames(out, file).has(n), + ) + if (dropped.length > 0) { + out = `import type {${dropped.join(', ')}} from '#/lexicons'\n` + out + } + return out + } + + const shadowOverlays = new Map() + const baselineOverlays = new Map() + let rewritten = 0 + for (const file of consumers) { + const code = fs.readFileSync(file, 'utf8') + const out = transformConsumer(code, file, true) + shadowOverlays.set(file, out) + baselineOverlays.set(file, transformConsumer(code, file, false)) + if (out.includes('_lex_')) rewritten++ + } + expect(rewritten).toBeGreaterThan(100) + + const options = loadAppCompilerOptions() + + function diagnose(overlays) { + const host = createOverlayHost(options, overlays) + const program = ts.createProgram(consumers, options, host) + const byFile = new Map() + for (const file of consumers) { + const sf = program.getSourceFile(file) + if (!sf) throw new Error(`${file} missing from program`) + byFile.set(file, fileDiagnostics(program, sf)) + } + return byFile + } + + const baseline = diagnose(baselineOverlays) + const shadow = diagnose(shadowOverlays) + + const diagKey = d => + `TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}` + const regressions = [] + for (const file of consumers) { + const known = new Set(baseline.get(file).map(diagKey)) + for (const d of shadow.get(file)) { + if (!known.has(diagKey(d))) regressions.push(d) + } + } + if (regressions.length > 0) { + throw new Error( + `plugin introduced diagnostics in app sources:\n${formatDiagnostics(regressions)}`, + ) + } + }, + TYPECHECK_TIMEOUT_MS, + ) +}) diff --git a/plugins/__tests__/lexiconLeafImports.test.ts b/plugins/__tests__/lexiconLeafImports.test.ts new file mode 100644 index 0000000000..9b26475ada --- /dev/null +++ b/plugins/__tests__/lexiconLeafImports.test.ts @@ -0,0 +1,376 @@ +/* + * 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. + */ +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' + +// 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') +const SDK_BARREL_RE = /(^|\/)lexicons\/index\.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 +} + +function resolveSpec(fromFile: string, spec: string): string | null { + const abs = path.resolve(path.dirname(fromFile), spec) + if (/\.(ts|tsx|js)$/.test(abs) && fs.existsSync(abs)) return abs + for (const ext of ['.ts', '.tsx', '.js']) { + if (fs.existsSync(abs + ext)) return abs + ext + } + return null +} + +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 +} + +/** + * The oracle's own barrel parser (written independently of the plugin's): + * Map of exported name -> source specifier for a file consisting purely of + * `export * as X from '...'` statements, or null for any other file shape - + * which is exactly what distinguishes a leaf from a barrel. + */ +const barrelCache = new Map | null>() +function barrelExports(file: string): Map | null { + let map = barrelCache.get(file) + if (map !== undefined) return map + map = new Map() + try { + const ast = parse(fs.readFileSync(file, 'utf8'), { + sourceType: 'module', + plugins: ['typescript'], + }) + for (const stmt of ast.program.body) { + if (stmt.type === 'ExportNamedDeclaration' && stmt.exportKind === 'type') + continue + if ( + stmt.type !== 'ExportNamedDeclaration' || + !stmt.source || + stmt.declaration || + stmt.specifiers.length === 0 || + !stmt.specifiers.every(s => s.type === 'ExportNamespaceSpecifier') + ) { + map = null + break + } + for (const s of stmt.specifiers) { + if (s.type === 'ExportNamespaceSpecifier') { + map.set(s.exported.name, stmt.source.value) + } + } + } + } catch { + map = null + } + barrelCache.set(file, map) + return map +} + +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 = resolveSpec(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 = resolveSpec(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 = resolveSpec(file, source) + leaves.set(source, resolved ?? ``) + continue + } + const resolved = source.startsWith('.') ? resolveSpec(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) +})