put everithing into one folder

This commit is contained in:
Oleksii Bulenok
2026-09-01 19:12:47 +02:00
parent 4ce3c4a278
commit 46e432bde5
5 changed files with 8 additions and 5 deletions
@@ -0,0 +1,662 @@
/*
* 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 {Worker} = require('node:worker_threads')
const ts = require('typescript')
const plugin = require('..')
const {barrelExports, resolveModuleFile} = require('../lexiconBarrels')
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('bails when the chain is a write target', () => {
const writes = [
'app.bsky.feed.like = 1',
'app.bsky.feed.like++',
'delete app.bsky.feed.like',
'for (app.bsky.feed.like of []) {}',
';[app.bsky.feed.like] = []',
';({x: app.bsky.feed.like} = {})',
]
for (const stmt of writes) {
const out = applyPlugin(
`import {app} from './lexicons'\n${stmt}\n`,
PROBE_FILE,
)
expect(out).toContain(`from './lexicons'`)
expect(out).not.toContain('import *')
}
})
test('rewrites a read of a leaf even when a sibling member is written', () => {
const out = applyPlugin(
`import {app} from './lexicons'\ndelete app.bsky.feed.like.$cached\n`,
PROBE_FILE,
)
expect(out).toContain('delete _lex_app_bsky_feed_like.$cached')
expect(out).not.toContain(`from './lexicons'`)
})
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('inserts leaf imports at the barrel import position, not the top', () => {
const out = applyPlugin(
`import './setup'\nimport {app} from './lexicons'\nvoid app.bsky.feed.like\n`,
PROBE_FILE,
)
const setupAt = out.indexOf(`'./setup'`)
const leafAt = out.indexOf('import * as _lex_app_bsky_feed_like')
expect(setupAt).toBeGreaterThanOrEqual(0)
expect(leafAt).toBeGreaterThan(setupAt)
expect(out).not.toContain(`from './lexicons'`)
})
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 *')
})
})
/*
* The plugin memoizes filesystem stats, barrel export maps, and verified
* chains in module-level caches that outlive individual transforms. A lexicon
* regen inside a long-lived worker (Metro dev server, jest --watch) must
* invalidate them - the plugin uses the root index mtime as the epoch.
*/
describe('cache invalidation across a lexicon regen', () => {
let tmp
let lexRoot
function write(rel, content) {
const file = path.join(tmp, rel)
fs.mkdirSync(path.dirname(file), {recursive: true})
fs.writeFileSync(file, content)
}
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lexicon-regen-'))
lexRoot = path.join(tmp, 'lexicons')
write('lexicons/index.ts', `export * as app from './app'\n`)
write('lexicons/app.ts', `export * as bsky from './app/bsky'\n`)
write('lexicons/app/bsky.ts', `export * as feed from './bsky/feed'\n`)
write('lexicons/app/bsky/feed.ts', `export * as like from './feed/like'\n`)
write('lexicons/app/bsky/feed/like.ts', `export const $type = 'test'\n`)
})
afterAll(() => {
fs.rmSync(tmp, {recursive: true, force: true})
})
function transform() {
return transformSync(
`import {app} from './lexicons'\nvoid app.bsky.feed.like\n`,
{
filename: path.join(tmp, 'consumer.ts'),
configFile: false,
babelrc: false,
parserOpts: {plugins: ['typescript']},
plugins: [[plugin, {roots: [lexRoot]}]],
},
).code
}
test('a layout change is picked up without a process restart', () => {
expect(transform()).toContain(
`import * as _lex_app_bsky_feed_like from "./lexicons/app/bsky/feed/like"`,
)
/*
* Simulate `lex build --clear` deepening the leaf into a barrel. Codegen
* rewrites the whole tree, so the root index mtime always moves; force it
* forward explicitly since same-millisecond writes would hide the change.
*/
write(
'lexicons/app/bsky/feed/like.ts',
`export * as main from './like/main'\n`,
)
write('lexicons/app/bsky/feed/like/main.ts', `export const $type = 'test'\n`)
const bumped = new Date(Date.now() + 10_000)
fs.utimesSync(path.join(lexRoot, 'index.ts'), bumped, bumped)
/*
* The chain now stops at a barrel, so the correct result is a bail that
* keeps the barrel import. Stale caches would instead replay the rewrite
* against the old layout.
*/
const out = transform()
expect(out).toContain(`from './lexicons'`)
expect(out).not.toContain('import *')
})
})
/**
* Enumerate every leaf chain by following `export * as <name> 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 = resolveModuleFile(fromFile, spec)
if (!abs) throw new Error(`cannot resolve '${spec}' from ${fromFile}`)
return abs
}
function walk(barrelFile, segments) {
const exports = barrelExports(barrelFile)
if (!exports) {
throw new Error(`${barrelFile} is not a pure namespace barrel`)
}
for (const [name, spec] of exports) {
const target = resolveTarget(barrelFile, spec)
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',
async () => {
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()
/*
* The baseline and shadow typechecks are independent CPU-bound
* programs, so each runs in its own worker thread and the two proceed
* in parallel. The worker returns diagnostics as plain records (see
* lexiconTypecheckWorker.js).
*/
function diagnose(overlays) {
return new Promise((resolve, reject) => {
const worker = new Worker(
path.join(__dirname, '..', 'lexiconTypecheckWorker.js'),
{workerData: {consumers, overlays, options}},
)
worker.once('message', byFile =>
resolve(new Map(Object.entries(byFile))),
)
worker.once('error', reject)
worker.once('exit', code => {
if (code !== 0) {
reject(new Error(`typecheck worker exited with code ${code}`))
}
})
})
}
const [baseline, shadow] = await Promise.all([
diagnose(baselineOverlays),
diagnose(shadowOverlays),
])
const diagKey = d => `TS${d.code}: ${d.message}`
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) {
const details = regressions
.slice(0, 20)
.map(d =>
d.fileName
? `${d.fileName}:${d.line} TS${d.code}: ${d.message}`
: `TS${d.code}: ${d.message}`,
)
.join('\n')
throw new Error(
`plugin introduced diagnostics in app sources:\n${details}`,
)
}
},
TYPECHECK_TIMEOUT_MS,
)
})
@@ -0,0 +1,111 @@
/*
* 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/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
}
/**
* 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<string, string> {
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)
})
@@ -0,0 +1,412 @@
/*
* Lexicon-only "tree shaking".
*
* The @atproto/lex codegen exposes every lexicon through nested namespace
* barrels (`export * as app from './app'` -> `export * as bsky ...` -> leaf),
* and consumers write `app.bsky.feed.like.$build(...)`. Bundlers see a single
* used binding (`app`) whose value is an opaque namespace object, so every leaf
* stays in the bundle. This plugin rewrites static member chains that start at
* a barrel import into a direct namespace import of the leaf module:
*
* import {app} from '#/lexicons'
* app.bsky.feed.like.$build(x)
*
* becomes
*
* import * as _lex_app_bsky_feed_like from '../lexicons/app/bsky/feed/like'
* _lex_app_bsky_feed_like.$build(x)
*
* Once nothing imports the barrels, Metro's reachability drops them and every
* unreferenced leaf. The same rewrite is applied to @bsky/sdk's compiled output,
* which imports its own copy of the barrel via '../lexicons/index.js'.
*
* Correctness fallback: if any reference to a barrel binding cannot be rewritten
* (namespace used as a value, computed access, chain ending at a non-leaf,
* chain in a write position), that binding is left on the barrel import. The result is always correct, merely
* unshaken for that file. Set BSKY_LEXICON_IMPORTS_DEBUG=1 to log such bails.
*
* Caveat: the rewrite bakes leaf file paths into each consumer's transform
* output, but Metro's and babel-jest's persistent caches key only on the
* consumer's own content, so a regen that MOVES leaf files (or a @bsky/sdk
* upgrade that reshuffles dist) can leave unchanged consumers replaying stale
* leaf imports from a warm cache. Symptoms are a module-not-found error in a
* file you did not touch or, if the old path still resolves, a runtime
* undefined-member error. Restart with `expo start -c` (or clear the Jest
* cache) after such a regen.
*
* Leaf vs barrel is decided from the filesystem: a segment with both `<seg>.ts`
* (or `.js`) and a `<seg>/` directory is a barrel, a segment with only the file
* is a leaf. This keeps the plugin independent of nesting depth (most NSIDs are
* four segments, but e.g. com.germnetwork.declaration is three).
*
* The filesystem walk assumes barrel re-export names mirror file names 1:1,
* which holds for codegen output but is not enforced by anything. So before
* rewriting, each chain is verified against the actual barrel sources: starting
* at the barrel's index file, follow the `export * as <segment> from '...'`
* statement for every segment and require that walk to land on the same leaf
* file the filesystem walk picked. `export * as X` yields the same module
* namespace object as importing the target directly, so file identity is
* sufficient proof that the rewrite preserves semantics. Any divergence
* (renamed re-export, unexpected barrel shape) is a hard build error: a
* rewrite that points at the wrong module must never ship silently.
*/
const fs = require('node:fs')
const path = require('node:path')
const {
EXTS,
SDK_BARREL_RE,
resolveModuleFile,
barrelExports,
clearBarrelExportCache,
} = require('./lexiconBarrels')
const statCache = new Map()
/** @returns {'dir' | 'file' | null} */
function classify(dir, segment) {
const key = path.join(dir, segment)
let kind = statCache.get(key)
if (kind !== undefined) return kind
kind = null
try {
if (fs.statSync(key).isDirectory()) kind = 'dir'
} catch {}
if (kind === null) {
for (const ext of EXTS) {
if (fs.existsSync(key + ext)) {
kind = 'file'
break
}
}
} else {
// A directory plus a sibling file of the same name is a barrel. A bare
// directory with no sibling file is not something codegen produces.
if (!EXTS.some(ext => fs.existsSync(key + ext))) kind = null
}
statCache.set(key, kind)
return kind
}
function leafFileFor(dir, segment) {
const key = path.join(dir, segment)
for (const ext of EXTS) {
if (fs.existsSync(key + ext)) return key + ext
}
return null
}
/**
* True when the member chain is written to rather than read. Such a chain
* cannot be collapsed into a bare identifier: imports are read-only bindings
* (assignment/++ would throw where the original property write may not) and
* `delete <identifier>` is a strict-mode SyntaxError in the emitted code.
*/
function isWriteTarget(memberPath) {
const parent = memberPath.parentPath
if (!parent) return false
if (parent.isAssignmentExpression()) {
return parent.node.left === memberPath.node
}
if (parent.isUpdateExpression()) return true
if (parent.isUnaryExpression({operator: 'delete'})) return true
if (parent.isForXStatement()) return parent.node.left === memberPath.node
if (parent.isArrayPattern() || parent.isRestElement()) return true
if (parent.isObjectProperty() && parent.parentPath.isObjectPattern()) {
return parent.node.value === memberPath.node
}
return false
}
/** `rootDir\0segments\0leafFile` -> boolean */
const chainCache = new Map()
/**
* Prove a planned rewrite correct by following the real export graph: parse
* each barrel on the way down and require the `export * as` chain to resolve
* to the exact leaf file the filesystem walk picked. Returns false on any
* divergence; the caller turns that into a hard build error.
*/
function verifyChain(rootDir, segments, leafFile) {
/*
* leafFile is part of the key: the cached boolean encodes `walk === leafFile`,
* so a hit for the same chain but a different planned leaf must not be reused.
*/
const key = rootDir + '\0' + segments.join('.') + '\0' + leafFile
let ok = chainCache.get(key)
if (ok !== undefined) return ok
let cur = leafFileFor(rootDir, 'index')
ok = cur !== null
if (ok) {
for (const segment of segments) {
const spec = barrelExports(cur)?.get(segment)
cur = spec ? resolveModuleFile(cur, spec) : null
if (!cur) {
ok = false
break
}
}
}
if (ok) ok = cur === leafFile
chainCache.set(key, ok)
return ok
}
/** Barrel root dir -> mtimeMs of its index file when the caches were filled. */
const rootEpochs = new Map()
/*
* The caches above are module-level and would otherwise outlive a lexicon
* regen inside a long-lived Metro or Jest watch worker, serving stale stats,
* export maps, and verified chains until the process restarts. Codegen
* (`lex build --clear`) rewrites the whole tree including the root index, so
* the index mtime works as an epoch: when it moves, drop all three caches.
* Costs one statSync per file that actually imports a barrel.
*/
function invalidateStaleCaches(rootDir) {
let mtime = -1
for (const ext of EXTS) {
try {
mtime = fs.statSync(path.join(rootDir, 'index' + ext)).mtimeMs
break
} catch {}
}
const prev = rootEpochs.get(rootDir)
if (prev !== undefined && prev !== mtime) {
statCache.clear()
clearBarrelExportCache()
chainCache.clear()
}
rootEpochs.set(rootDir, mtime)
}
const SDK_SEGMENT = `${path.sep}@bsky${path.sep}sdk${path.sep}`
module.exports = function lexiconLeafImports(babel, options = {}) {
const {types: t} = babel
/**
* Absolute paths of barrel directories (e.g. <root>/src/lexicons). Matched
* against the import specifier after resolving it relative to the importing
* file, because babel-plugin-module-resolver has already turned '#/lexicons'
* into a relative path by the time this plugin runs. Relative entries are
* resolved against Babel's root, not process.cwd(): cwd depends on how the
* host process (Metro worker, Jest, an IDE runner) was launched, and a wrong
* base would silently disable every rewrite.
*/
let roots = null
function resolveRoots(state) {
if (!roots) {
const base = state.file.opts.root ?? state.cwd
roots = new Set((options.roots ?? []).map(r => path.resolve(base, r)))
}
}
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) {
if (source.startsWith('.')) {
const abs = path
.resolve(path.dirname(filename), source)
.replace(/[\\/]index(\.[jt]s)?$/, '')
if (roots.has(abs)) return abs
if (SDK_BARREL_RE.test(source) && filename.includes(SDK_SEGMENT)) {
return abs
}
}
return null
}
/**
* Walk the member chain hanging off a reference to a barrel binding down to
* the leaf module. Returns the MemberExpression path whose value is the leaf
* namespace plus the leaf's absolute file path, or null if the chain cannot
* be rewritten safely.
*/
function planRewrite(refPath, rootDir, rootSegment) {
let dir = rootDir
let segment = rootSegment
let cur = refPath
const segments = [rootSegment]
for (;;) {
const kind = classify(dir, segment)
if (kind === 'file') {
if (isWriteTarget(cur)) return null
return {memberPath: cur, leafFile: leafFileFor(dir, segment), segments}
}
if (kind !== 'dir') return null
const parent = cur.parentPath
if (
!parent ||
!parent.isMemberExpression() ||
parent.node.object !== cur.node ||
parent.node.computed ||
!t.isIdentifier(parent.node.property)
) {
return null
}
dir = path.join(dir, segment)
segment = parent.node.property.name
segments.push(segment)
cur = parent
}
}
function toSpecifier(fromFile, leafFile) {
let rel = path
.relative(path.dirname(fromFile), leafFile)
.split(path.sep)
.join('/')
// Keep `.js` for the SDK's ESM output; strip `.ts` so Metro resolves
// platform extensions for app sources normally.
rel = rel.replace(/\.ts$/, '')
if (!rel.startsWith('.')) rel = './' + rel
return rel
}
return {
name: 'lexicon-leaf-imports',
visitor: {
Program: {
exit(programPath, state) {
const filename = state.filename
if (!filename) return
resolveRoots(state)
const targets = []
for (const stmt of programPath.get('body')) {
if (!stmt.isImportDeclaration()) continue
if (stmt.node.importKind === 'type') continue
/*
* A specifier-less import exists only for module evaluation; there is
* nothing to rewrite, so leave it untouched.
*/
if (stmt.node.specifiers.length === 0) continue
const dir = barrelDirFor(stmt.node.source.value, filename)
if (dir) targets.push({imp: stmt, dir})
}
if (targets.length === 0) return
for (const {dir} of targets) {
invalidateStaleCaches(dir)
}
/*
* Type-only references were stripped by the TypeScript transform
* earlier in this traversal; re-crawl so referencePaths reflect the
* current tree.
*/
programPath.scope.crawl()
const leafImports = new Map()
let pendingDecls = []
let touched = false
function namespaceIdFor(leafFile) {
let id = leafImports.get(leafFile)
if (id) return id
const hint =
'lex_' +
leafFile
.replace(/\.(ts|js)$/, '')
.split(path.sep)
.slice(-4)
.join('_')
.replace(/[^A-Za-z0-9_]/g, '_')
id = programPath.scope.generateUidIdentifier(hint)
leafImports.set(leafFile, id)
pendingDecls.push(
t.importDeclaration(
[t.importNamespaceSpecifier(t.cloneNode(id))],
t.stringLiteral(toSpecifier(filename, leafFile)),
),
)
return id
}
for (const {imp, dir} of targets) {
pendingDecls = []
const keep = []
for (const spec of imp.get('specifiers')) {
if (
!spec.isImportSpecifier() ||
spec.node.importKind === 'type'
) {
keep.push(spec.node)
continue
}
const imported = spec.node.imported
const rootSegment = t.isIdentifier(imported)
? imported.name
: imported.value
const binding = programPath.scope.getBinding(spec.node.local.name)
if (!binding || binding.path !== spec) {
keep.push(spec.node)
continue
}
const plan = []
let ok = true
for (const ref of binding.referencePaths) {
const r = planRewrite(ref, dir, rootSegment)
if (r && !verifyChain(dir, r.segments, r.leafFile)) {
throw ref.buildCodeFrameError(
`[lexicon-leaf-imports] filesystem walk resolved '${r.segments.join('.')}' to ${path.relative(process.cwd(), r.leafFile)}, but following the barrel's own 'export * as' chain does not reach that file. The barrel layout no longer matches the plugin's assumptions; fix the barrels or the plugin.`,
)
}
if (!r) {
ok = false
if (debug) {
const loc = ref.node.loc?.start
console.warn(
`[lexicon-leaf-imports] bail: ${path.relative(process.cwd(), filename)}:${loc?.line}:${loc?.column} (${rootSegment})`,
)
}
break
}
plan.push(r)
}
if (!ok) {
stats.bails++
keep.push(spec.node)
continue
}
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++
}
touched = true
}
/*
* Insert the leaf imports at the barrel import's own position so
* they evaluate exactly when the barrel would have - hoisting them
* to the top of the file would run the lexicon modules ahead of
* ordering-sensitive side-effect imports (polyfills, sentry setup).
*/
if (pendingDecls.length > 0) {
imp.insertBefore(pendingDecls)
}
if (keep.length === 0) {
imp.remove()
} else if (keep.length !== imp.node.specifiers.length) {
imp.node.specifiers = keep
}
}
if (touched) stats.files++
if (debug && touched) {
console.warn(
`[lexicon-leaf-imports] ${path.relative(process.cwd(), filename)}: ${leafImports.size} leaf imports (total files=${stats.files} rewrites=${stats.rewrites} bails=${stats.bails})`,
)
}
},
},
},
}
}
@@ -0,0 +1,94 @@
/*
* Shared mechanics for reading the generated lexicon barrels, used by
* babel-plugin-lexicon-leaf-imports and its test suites. The tests keep their
* own walking logic (an export-graph oracle vs the plugin's filesystem
* heuristic); only this mechanical layer - how a specifier resolves to a file
* and how a barrel parses into its re-export map - is shared, so the copies
* cannot drift apart.
*/
const fs = require('node:fs')
const parser = require('@babel/parser')
const path = require('node:path')
/** Extensions codegen emits: `.ts` for app sources, `.js` for the SDK dist. */
const EXTS = ['.ts', '.js']
const EXT_RE = new RegExp(`\\.(${EXTS.map(e => e.slice(1)).join('|')})$`)
/** Matches the SDK's compiled barrel entry, e.g. '../lexicons/index.js'. */
const SDK_BARREL_RE = /(^|\/)lexicons\/index\.js$/
/**
* Resolve a barrel/leaf specifier relative to the importing file: the exact
* path when it already carries a known extension, otherwise the first of
* EXTS that exists.
*
* @param {string} fromFile
* @param {string} spec
* @returns {string | null} absolute file path, or null
*/
function resolveModuleFile(fromFile, spec) {
const abs = path.resolve(path.dirname(fromFile), spec)
if (EXT_RE.test(abs) && fs.existsSync(abs)) return abs
for (const ext of EXTS) {
if (fs.existsSync(abs + ext)) return abs + ext
}
return null
}
/** Absolute barrel file -> Map(exported name -> source specifier), or null. */
const barrelExportCache = new Map()
/**
* Parse a barrel file into its namespace re-export map. Returns null if the
* file contains anything other than `export * as X from '...'` statements
* (plus type-only exports) - chains through such a file cannot be proven, so
* lookups fail and the caller bails.
*
* @param {string} file
* @returns {Map<string, string> | null}
*/
function barrelExports(file) {
let map = barrelExportCache.get(file)
if (map !== undefined) return map
map = new Map()
try {
const ast = parser.parse(fs.readFileSync(file, 'utf8'), {
sourceType: 'module',
plugins: ['typescript'],
})
for (const stmt of ast.program.body) {
if (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) {
const name =
s.exported.type === 'Identifier' ? s.exported.name : s.exported.value
map.set(name, stmt.source.value)
}
}
} catch {
map = null
}
barrelExportCache.set(file, map)
return map
}
function clearBarrelExportCache() {
barrelExportCache.clear()
}
module.exports = {
EXTS,
SDK_BARREL_RE,
resolveModuleFile,
barrelExports,
clearBarrelExportCache,
}
@@ -0,0 +1,48 @@
/*
* Worker thread for the "app callsites" test in
* __tests__/lexiconLeafImports.test.js: typechecks the
* consumer files with the given overlay contents and reports error
* diagnostics. The baseline and shadow typechecks are independent CPU-bound
* programs, so the test runs one worker for each in parallel.
*
* Lives outside __tests__/ so Jest does not collect it as a test suite.
* ts.Diagnostic objects do not survive structured clone, so diagnostics are
* flattened to plain {code, message, fileName, line} records here.
*/
const {parentPort, workerData} = require('node:worker_threads')
const ts = require('typescript')
const {consumers, overlays, options} = workerData
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)
const program = ts.createProgram(consumers, options, host)
const byFile = {}
for (const file of consumers) {
const sf = program.getSourceFile(file)
if (!sf) throw new Error(`${file} missing from program`)
byFile[file] = [
...program.getSyntacticDiagnostics(sf),
...program.getSemanticDiagnostics(sf),
]
.filter(d => d.category === ts.DiagnosticCategory.Error)
.map(d => ({
code: d.code,
message: ts.flattenDiagnosticMessageText(d.messageText, ' '),
fileName: d.file?.fileName,
line:
d.file && d.start !== undefined
? d.file.getLineAndCharacterOfPosition(d.start).line + 1
: undefined,
}))
}
parentPort.postMessage(byFile)