From c26da684c9f8536b1fd7896897633a9534bddb09 Mon Sep 17 00:00:00 2001 From: Oleksii Bulenok Date: Mon, 31 Aug 2026 15:30:14 +0200 Subject: [PATCH] refactor --- .../babel-plugin-lexicon-leaf-imports.test.js | 41 +++----- plugins/__tests__/lexiconLeafImports.test.ts | 71 +++----------- plugins/babel-plugin-lexicon-leaf-imports.js | 67 ++----------- plugins/lexiconBarrels.js | 94 +++++++++++++++++++ 4 files changed, 134 insertions(+), 139 deletions(-) create mode 100644 plugins/lexiconBarrels.js diff --git a/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js b/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js index a1e8f9eb3a..93e9d22011 100644 --- a/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js +++ b/plugins/__tests__/babel-plugin-lexicon-leaf-imports.test.js @@ -30,6 +30,7 @@ const path = require('node:path') const ts = require('typescript') const plugin = require('../babel-plugin-lexicon-leaf-imports') +const {barrelExports, resolveModuleFile} = require('../lexiconBarrels') const ROOT = path.resolve(__dirname, '../..') const LEXICONS_ROOT = path.join(ROOT, 'src', 'lexicons') @@ -187,35 +188,23 @@ 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}`) + const abs = resolveModuleFile(fromFile, spec) + if (!abs) throw new Error(`cannot resolve '${spec}' from ${fromFile}`) + return abs } 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]) - } + 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]) } } } diff --git a/plugins/__tests__/lexiconLeafImports.test.ts b/plugins/__tests__/lexiconLeafImports.test.ts index 9b26475ada..d2328d5af2 100644 --- a/plugins/__tests__/lexiconLeafImports.test.ts +++ b/plugins/__tests__/lexiconLeafImports.test.ts @@ -12,6 +12,11 @@ * 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' @@ -20,6 +25,12 @@ 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 @@ -27,7 +38,6 @@ 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[] = [] @@ -42,15 +52,6 @@ function listFiles(dir: string, exts: string[]): string[] { 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) @@ -61,48 +62,6 @@ function contentHash(file: string): string { 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} @@ -127,7 +86,7 @@ function walkChain( 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) + 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 @@ -184,7 +143,7 @@ function computeExpectation(file: string, code: string): Expectation { if (!isSdk && source === '#/lexicons') { entryBarrel = APP_BARREL_ENTRY } else if (isSdk && SDK_BARREL_RE.test(source)) { - entryBarrel = resolveSpec(file, source) + entryBarrel = resolveModuleFile(file, source) } if (!entryBarrel) continue @@ -283,11 +242,11 @@ function collectActual(file: string, output: string): Actual { s => s.type === 'ImportNamespaceSpecifier' && /^_lex_/.test(s.local.name), ) if (ns) { - const resolved = resolveSpec(file, source) + const resolved = resolveModuleFile(file, source) leaves.set(source, resolved ?? ``) continue } - const resolved = source.startsWith('.') ? resolveSpec(file, source) : null + const resolved = source.startsWith('.') ? resolveModuleFile(file, source) : null if ( resolved === APP_BARREL_ENTRY || (isSdk && SDK_BARREL_RE.test(source) && resolved) diff --git a/plugins/babel-plugin-lexicon-leaf-imports.js b/plugins/babel-plugin-lexicon-leaf-imports.js index 955887315b..faf9fb6382 100644 --- a/plugins/babel-plugin-lexicon-leaf-imports.js +++ b/plugins/babel-plugin-lexicon-leaf-imports.js @@ -52,9 +52,15 @@ */ const fs = require('node:fs') const path = require('node:path') -const parser = require('@babel/parser') -const EXTS = ['.ts', '.js'] +const { + EXTS, + SDK_BARREL_RE, + resolveModuleFile, + barrelExports, + clearBarrelExportCache, +} = require('./lexiconBarrels') + const statCache = new Map() /** @returns {'dir' | 'file' | null} */ @@ -90,58 +96,6 @@ function leafFileFor(dir, segment) { 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. - */ -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 resolveBarrelTarget(fromFile, spec) { - const abs = path.resolve(path.dirname(fromFile), spec) - if (/\.(ts|js)$/.test(abs) && fs.existsSync(abs)) return abs - for (const ext of EXTS) { - if (fs.existsSync(abs + ext)) return abs + ext - } - return null -} - /** `rootDir\0segments\0leafFile` -> boolean */ const chainCache = new Map() @@ -164,7 +118,7 @@ function verifyChain(rootDir, segments, leafFile) { if (ok) { for (const segment of segments) { const spec = barrelExports(cur)?.get(segment) - cur = spec ? resolveBarrelTarget(cur, spec) : null + cur = spec ? resolveModuleFile(cur, spec) : null if (!cur) { ok = false break @@ -198,14 +152,13 @@ function invalidateStaleCaches(rootDir) { const prev = rootEpochs.get(rootDir) if (prev !== undefined && prev !== mtime) { statCache.clear() - barrelExportCache.clear() + clearBarrelExportCache() chainCache.clear() } rootEpochs.set(rootDir, mtime) } const SDK_SEGMENT = `${path.sep}@bsky${path.sep}sdk${path.sep}` -const SDK_BARREL_RE = /(^|\/)lexicons\/index\.js$/ module.exports = function lexiconLeafImports(babel, options = {}) { const {types: t} = babel diff --git a/plugins/lexiconBarrels.js b/plugins/lexiconBarrels.js new file mode 100644 index 0000000000..7cb78499c8 --- /dev/null +++ b/plugins/lexiconBarrels.js @@ -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 | 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, +}