This commit is contained in:
Oleksii Bulenok
2026-08-31 15:30:14 +02:00
parent 1afff03313
commit c26da684c9
4 changed files with 134 additions and 139 deletions
@@ -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])
}
}
}
+15 -56
View File
@@ -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<string, string>()
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<string, Map<string, string> | null>()
function barrelExports(file: string): Map<string, string> | 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 ?? `<unresolvable: ${source}>`)
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)
+10 -57
View File
@@ -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
+94
View File
@@ -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,
}