From 9a6eda44d9ababb365c33206d0cb79a8cb689ecc Mon Sep 17 00:00:00 2001 From: Ben Riemer Date: Tue, 17 Mar 2026 15:32:56 +0000 Subject: [PATCH] feat: add asset location tracking to analysis results Adds location tracking to the analyze function to capture where assets, dependencies, and imports are referenced in source code. - Add AssetLocation interface with start/end/type fields - Add assetsLocation Map to AnalyzeResult (optional, off by default) - Add trackLocations option to enable location tracking - Track character offsets for imports, requires, fs operations, path.join, etc. This enables downstream tools (bundlers, transformers) to rewrite asset references while preserving source maps and code structure. Closes #567 --- src/analyze.ts | 159 +++++++++++++++++++++++++++++++++++------ src/node-file-trace.ts | 79 ++++++++++++-------- src/types.ts | 16 +++++ 3 files changed, 205 insertions(+), 49 deletions(-) diff --git a/src/analyze.ts b/src/analyze.ts index 1b63ebd6..050c8b6a 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -27,6 +27,7 @@ import nodeGypBuild from 'node-gyp-build'; import mapboxPregyp from '@mapbox/node-pre-gyp'; import { Job } from './node-file-trace'; import { fileURLToPath, pathToFileURL, URL } from 'url'; +import { AssetLocation } from './types'; // Note: these should be deprecated over time as they ship in Acorn core const acorn = Parser.extend( @@ -253,6 +254,7 @@ export interface AnalyzeResult { deps: Set; imports: Set; isESM: boolean; + assetsLocation?: Map; } export default async function analyze( @@ -264,6 +266,47 @@ export default async function analyze( const deps = new Set(); const imports = new Set(); + // Location tracking (optional, enabled via job.analysis.trackLocations) + const assetsLocation = job.analysis.trackLocations + ? new Map() + : undefined; + + // Helper function to convert AST position to line/column/character + function getLocationFromPosition( + pos: { line: number; column: number }, + code: string, + ): { line: number; column: number; character: number } { + // Calculate character offset from line and column + const lines = code.split('\n'); + let character = 0; + for (let i = 0; i < pos.line - 1 && i < lines.length; i++) { + character += lines[i].length + 1; // +1 for newline + } + character += pos.column; + return { + line: pos.line, + column: pos.column, + character, + }; + } + + // Helper function to track location of an asset/dep/import + function trackLocation( + type: string, + value: string, + start: { line: number; column: number }, + end: { line: number; column: number }, + ) { + if (!assetsLocation) return; + const existing = assetsLocation.get(value) || []; + existing.push({ + start: getLocationFromPosition(start, code), + end: getLocationFromPosition(end, code), + type, + }); + assetsLocation.set(value, existing); + } + const dir = path.dirname(id); // if (typeof options.production === 'boolean' && staticProcess.env.NODE_ENV === UNKNOWN) // staticProcess.env.NODE_ENV = options.production ? 'production' : 'dev'; @@ -320,6 +363,7 @@ export default async function analyze( ast = acorn.parse(code, { ecmaVersion: 'latest', allowReturnOutsideFunction: true, + locations: true, }); isESM = false; } catch (e: any) { @@ -337,6 +381,7 @@ export default async function analyze( ecmaVersion: 'latest', sourceType: 'module', allowAwaitOutsideFunction: true, + locations: true, }); isESM = true; } catch (e: any) { @@ -431,6 +476,15 @@ export default async function analyze( if (decl.type === 'ImportDeclaration') { const source = String(decl.source.value); deps.add(source); + // Track location + if (decl.source.loc) { + trackLocation( + 'import', + source, + decl.source.loc.start, + decl.source.loc.end, + ); + } const staticModule = staticModules[source.startsWith('node:') ? source.slice(5) : source]; if (staticModule) { @@ -455,7 +509,19 @@ export default async function analyze( decl.type === 'ExportNamedDeclaration' || decl.type === 'ExportAllDeclaration' ) { - if (decl.source) deps.add(String(decl.source.value)); + if (decl.source) { + const source = String(decl.source.value); + deps.add(source); + // Track location + if (decl.source.loc) { + trackLocation( + 'import', + source, + decl.source.loc.start, + decl.source.loc.end, + ); + } + } } } } @@ -532,34 +598,59 @@ export default async function analyze( }); } - async function processRequireArg(expression: Node, isImport = false) { + async function processRequireArg( + expression: Node, + isImport = false, + callNode?: Node, + ) { if (expression.type === 'ConditionalExpression') { - await processRequireArg(expression.consequent, isImport); - await processRequireArg(expression.alternate, isImport); + await processRequireArg(expression.consequent, isImport, callNode); + await processRequireArg(expression.alternate, isImport, callNode); return; } if (expression.type === 'LogicalExpression') { - await processRequireArg(expression.left, isImport); - await processRequireArg(expression.right, isImport); + await processRequireArg(expression.left, isImport, callNode); + await processRequireArg(expression.right, isImport, callNode); return; } let computed = await computePureStaticValue(expression, true); if (!computed) return; - function add(value: string) { + function add( + value: string, + loc?: { + start: { line: number; column: number }; + end: { line: number; column: number }; + }, + ) { (isImport ? imports : deps).add(value); + // Track location if available + if (loc && callNode && callNode.callee) { + const callee = callNode.callee; + const type = + callee.type === 'Identifier' + ? `require.${callee.name}` + : callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' + ? `${callee.object.name}.${(callee.property as any).name}` + : isImport + ? 'import' + : 'require'; + trackLocation(type, value, loc.start, loc.end); + } } + const argLoc = expression.loc; if ('value' in computed && typeof computed.value === 'string') { - if (!computed.wildcards) add(computed.value); + if (!computed.wildcards) add(computed.value, argLoc); else if (computed.wildcards.length >= 1) emitWildcardRequire(computed.value); } else { if ('ifTrue' in computed && typeof computed.ifTrue === 'string') - add(computed.ifTrue); + add(computed.ifTrue, argLoc); if ('else' in computed && typeof computed.else === 'string') - add(computed.else); + add(computed.else, argLoc); } } @@ -658,7 +749,7 @@ export default async function analyze( staticChildNode = node; await backtrack(parent, this); } else if (node.type === 'ImportExpression') { - await processRequireArg(node.source, true); + await processRequireArg(node.source, true, node); return; } // Call expression cases and asset triggers @@ -678,7 +769,7 @@ export default async function analyze( knownBindings.require && knownBindings.require.shadowDepth === 0 ) { - await processRequireArg(node.arguments[0]); + await processRequireArg(node.arguments[0], false, node); return; } } else if ( @@ -692,7 +783,7 @@ export default async function analyze( node.callee.property.name === 'require' && node.arguments.length ) { - await processRequireArg(node.arguments[0]); + await processRequireArg(node.arguments[0], false, node); return; } else if ( (!isESM || job.mixedModules) && @@ -706,7 +797,7 @@ export default async function analyze( node.callee.property.name === 'resolve' && node.arguments.length ) { - await processRequireArg(node.arguments[0]); + await processRequireArg(node.arguments[0], false, node); return; } @@ -745,7 +836,7 @@ export default async function analyze( (!knownBindings.require || knownBindings.require.shadowDepth === 0) ) { - await processRequireArg(node.arguments[0]); + await processRequireArg(node.arguments[0], false, node); } break; // require('bindings')(...) @@ -828,9 +919,19 @@ export default async function analyze( ) { const bindingInfo = nbind(arg.value); if (bindingInfo && bindingInfo.path) { - deps.add( - path.relative(dir, bindingInfo.path).replace(/\\/g, '/'), - ); + const depPath = path + .relative(dir, bindingInfo.path) + .replace(/\\/g, '/'); + deps.add(depPath); + // Track location + if (node.loc) { + trackLocation( + 'nbind.init', + depPath, + node.loc.start, + node.loc.end, + ); + } return this.skip(); } } @@ -845,7 +946,7 @@ export default async function analyze( node.arguments[0].value === 'view engine' && !definedExpressEngines ) { - await processRequireArg(node.arguments[1]); + await processRequireArg(node.arguments[1], false, node); return this.skip(); } break; @@ -939,10 +1040,28 @@ export default async function analyze( : './' + srcPath; imports.add(relativeSrcPath); + // Track location + if (node.arguments[0].loc) { + trackLocation( + 'module.createRequire', + relativeSrcPath, + node.arguments[0].loc.start, + node.arguments[0].loc.end, + ); + } } } else { // It's a bare specifier, so just add into the imports imports.add(pathOrSpecifier); + // Track location + if (node.arguments[0].loc) { + trackLocation( + 'module.createRequire', + pathOrSpecifier, + node.arguments[0].loc.start, + node.arguments[0].loc.end, + ); + } } } break; @@ -1150,7 +1269,7 @@ export default async function analyze( }); await assetEmissionPromises; - return { assets, deps, imports, isESM }; + return { assets, deps, imports, isESM, assetsLocation }; async function emitAssetPath(assetPath: string) { // verify the asset file / directory exists diff --git a/src/node-file-trace.ts b/src/node-file-trace.ts index 34a83671..0f1bb341 100644 --- a/src/node-file-trace.ts +++ b/src/node-file-trace.ts @@ -61,6 +61,7 @@ export class Job { emitGlobs?: boolean; computeFileReferences?: boolean; evaluatePureExpressions?: boolean; + trackLocations?: boolean; }; private analysisCache: Map; public fileList: Set; @@ -71,56 +72,68 @@ export class Job { private cachedFileSystem: CachedFileSystem; private remappings: Map> = new Map(); - constructor({ - base = process.cwd(), - processCwd, - exports, - conditions = exports || ['node'], - exportsOnly = false, - paths = {}, - ignore, - log = false, - mixedModules = false, - ts = true, - analysis = {}, - cache, - // we use a default of 1024 concurrency to balance - // performance and memory usage for fs operations - fileIOConcurrency = 1024, - depth = Infinity, - }: NodeFileTraceOptions) { + constructor(opts: NodeFileTraceOptions = {}) { + const { + base = process.cwd(), + processCwd, + exports: exportsCond, + conditions = exportsCond || ['node'], + exportsOnly = false, + paths = {}, + ignore, + log = false, + mixedModules = false, + ts = true, + analysis = {}, + cache, + // we use a default of 1024 concurrency to balance + // performance and memory usage for fs operations + fileIOConcurrency = 1024, + depth = Infinity, + trackLocations, + } = opts; this.ts = ts; - base = resolve(base); + const resolvedBase = resolve(base); this.ignoreFn = (path: string) => { if (path.startsWith('..' + sep)) return true; return false; }; - if (typeof ignore === 'string') ignore = [ignore]; + let ignoreFn = this.ignoreFn; if (typeof ignore === 'function') { - const ig = ignore; - this.ignoreFn = (path: string) => { + ignoreFn = (path: string) => { if (path.startsWith('..' + sep)) return true; - if (ig(path)) return true; + if (ignore(path)) return true; return false; }; } else if (Array.isArray(ignore)) { - const resolvedIgnores = ignore.map((ignore) => - relative(base, resolve(base || process.cwd(), ignore)), + const resolvedIgnores = ignore.map((ign) => + relative(resolvedBase, resolve(resolvedBase || process.cwd(), ign)), ); - this.ignoreFn = (path: string) => { + ignoreFn = (path: string) => { + if (path.startsWith('..' + sep)) return true; + if (isMatch(path, resolvedIgnores)) return true; + return false; + }; + } else if (typeof ignore === 'string') { + const resolvedIgnore = [ignore]; + const resolvedIgnores = resolvedIgnore.map((ign) => + relative(resolvedBase, resolve(resolvedBase || process.cwd(), ign)), + ); + ignoreFn = (path: string) => { if (path.startsWith('..' + sep)) return true; if (isMatch(path, resolvedIgnores)) return true; return false; }; } - this.base = base; - this.cwd = resolve(processCwd || base); + this.ignoreFn = ignoreFn; + this.base = resolvedBase; + this.cwd = resolve(processCwd || resolvedBase); this.conditions = conditions; this.exportsOnly = exportsOnly; const resolvedPaths: Record = {}; for (const path of Object.keys(paths)) { const trailer = paths[path].endsWith('/'); - const resolvedPath = resolve(base, paths[path]); + const resolvedPath = resolve(resolvedBase, paths[path]); resolvedPaths[path] = resolvedPath + (trailer ? '/' : ''); } this.paths = resolvedPaths; @@ -141,11 +154,19 @@ export class Job { computeFileReferences: true, // evaluate known bindings to assist with glob and file reference analysis evaluatePureExpressions: true, + // whether to track character offsets for assets, deps, and imports + trackLocations: false, }, analysis === true ? {} : analysis, ); } + // Support top-level trackLocations option (also via analysis object) + if (trackLocations !== undefined) { + this.analysis = this.analysis || {}; + this.analysis.trackLocations = trackLocations; + } + this.analysisCache = (cache && cache.analysisCache) || new Map(); if (cache) { diff --git a/src/types.ts b/src/types.ts index 39dfd375..15d4a031 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,12 @@ export interface Stats { birthtime: Date; } +export interface AssetLocation { + start: { line: number; column: number; character: number }; + end: { line: number; column: number; character: number }; + type: string; +} + export interface NodeFileTraceOptions { base?: string; processCwd?: string; @@ -41,6 +47,7 @@ export interface NodeFileTraceOptions { emitGlobs?: boolean; computeFileReferences?: boolean; evaluatePureExpressions?: boolean; + trackLocations?: boolean; }; cache?: any; paths?: Record; @@ -58,6 +65,15 @@ export interface NodeFileTraceOptions { ) => Promise; fileIOConcurrency?: number; depth?: number; + trackLocations?: boolean; +} + +export interface AnalyzeResult { + assets: Set; + deps: Set; + imports: Set; + isESM: boolean; + assetsLocation?: Map; } export type NodeFileTraceReasonType =