diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js new file mode 100644 index 00000000..45209bad --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js @@ -0,0 +1,12 @@ +import { boot } from 'Lib'; +import { trim } from 'Util/trim'; +// CONTROL: 'Lib$' is exact, and 'Loose' has no fixed directory: both stay unresolved +import { extra } from 'Lib/extra'; +import { extra as loose } from 'Loose/extra'; + +export function start(s) { + boot(); + extra(); + loose(); + return trim(s); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js new file mode 100644 index 00000000..814444de --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js @@ -0,0 +1,3 @@ +export function extra() { + return 2; +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js new file mode 100644 index 00000000..43a40916 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js @@ -0,0 +1,3 @@ +export function boot() { + return 1; +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js new file mode 100644 index 00000000..408e0649 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js @@ -0,0 +1,3 @@ +export function trim(s) { + return s.trim(); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs new file mode 100644 index 00000000..53450f19 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs @@ -0,0 +1,13 @@ +const path = require('path'); + +module.exports = { + resolve: { + alias: { + // exact match only: 'Lib' resolves, 'Lib/extra' does not + Lib$: path.resolve(__dirname, 'lib/index.js'), + Util: path.join(__dirname, 'lib/util'), + // CONTROL: a bare relative replacement is read from the importer, so it fixes nothing + Loose: './lib', + }, + }, +}; diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json new file mode 100644 index 00000000..cffb09dd --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./own/*"] } } } diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js new file mode 100644 index 00000000..c8b5d61b --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js @@ -0,0 +1,3 @@ +export function fmtDate(d) { + return `own ${d}`; +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js new file mode 100644 index 00000000..1b8e2c63 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js @@ -0,0 +1,6 @@ +// CONTROL: the nearest jsconfig.json maps '@/*' itself, and that mapping wins over vite.config.js +import { fmtDate } from '@/date'; + +export function probe(d) { + return fmtDate(d); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/package.json b/graph/test/javascript/cases/71-bundler-config-alias/src/package.json new file mode 100644 index 00000000..42903152 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/package.json @@ -0,0 +1 @@ +{ "name": "bundler-alias-fixture", "type": "module" } diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js b/graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js new file mode 100644 index 00000000..80f99618 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js @@ -0,0 +1,3 @@ +export function fmtMoney(n) { + return n.toFixed(2); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js b/graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js new file mode 100644 index 00000000..b0c1a0ed --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js @@ -0,0 +1,12 @@ +// '@' from vite.config.js resolve.alias: resolved to src/utils/date.js +import { fmtDate } from '@/utils/date'; +// '~shared' through fileURLToPath(new URL(...)): resolved to shared/fmt.js +import { fmtMoney } from '~shared/fmt'; +// CONTROL: '@utils/date' is not '@' or '@/…', so the '@' alias does not apply +import { fmtDate as scoped } from '@utils/date'; + +export function render(order) { + fmtMoney(order.total); + scoped(order.at); + return fmtDate(order.at); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js b/graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js new file mode 100644 index 00000000..be60df34 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js @@ -0,0 +1,3 @@ +export function fmtDate(d) { + return String(d); +} diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js b/graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js new file mode 100644 index 00000000..14da4645 --- /dev/null +++ b/graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import path from 'path'; +import { fileURLToPath, URL } from 'node:url'; + +// the only place '@' and '~shared' are declared: nothing nearer maps them +export default defineConfig(({ mode }) => ({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '~shared': fileURLToPath(new URL('./shared', import.meta.url)), + }, + }, +})); diff --git a/graph/test/javascript/expected/71-bundler-config-alias.diag b/graph/test/javascript/expected/71-bundler-config-alias.diag new file mode 100644 index 00000000..d46236f8 --- /dev/null +++ b/graph/test/javascript/expected/71-bundler-config-alias.diag @@ -0,0 +1,21 @@ +import_cause legacy/app.js:4:10 Lib/extra not_staged +import_cause legacy/app.js:5:10 Loose/extra not_staged +import_cause legacy/webpack.config.cjs:1:14 path builtin +import_cause src/pages/Home.js:6:10 @utils/date not_staged +import_cause vite.config.js:1:10 vite not_staged +import_cause vite.config.js:2:1 path builtin +import_cause vite.config.js:3:10 node:url builtin +import_cause vite.config.js:3:25 node:url builtin +package_entry bundler-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - +unresolved legacy/app.js:10:3 FUNCTION_CALL loose callee_untyped +unresolved legacy/app.js:9:3 FUNCTION_CALL extra callee_untyped +unresolved legacy/lib/util/trim.js:2:10 METHOD_CALL trim receiver_untyped +unresolved legacy/webpack.config.cjs:7:13 METHOD_CALL resolve no_target +unresolved legacy/webpack.config.cjs:8:13 METHOD_CALL join no_target +unresolved shared/fmt.js:2:10 METHOD_CALL toFixed receiver_untyped +unresolved src/pages/Home.js:10:3 FUNCTION_CALL scoped callee_untyped +unresolved src/utils/date.js:2:10 FUNCTION_CALL String no_target +unresolved vite.config.js:10:18 FUNCTION_CALL fileURLToPath no_target +unresolved vite.config.js:10:32 CONSTRUCTOR_CALL URL no_target +unresolved vite.config.js:6:16 FUNCTION_CALL defineConfig callee_untyped +unresolved vite.config.js:9:12 METHOD_CALL resolve receiver_untyped diff --git a/graph/test/javascript/expected/71-bundler-config-alias.edges b/graph/test/javascript/expected/71-bundler-config-alias.edges new file mode 100644 index 00000000..db2535ff --- /dev/null +++ b/graph/test/javascript/expected/71-bundler-config-alias.edges @@ -0,0 +1,18 @@ +legacy/app.js:10:3 FUNCTION_CALL loose -> ambiguous_unknown - +legacy/app.js:11:10 FUNCTION_CALL trim -> known_edge legacy/lib/util/trim.js:1:1 trim +legacy/app.js:8:3 FUNCTION_CALL boot -> known_edge legacy/lib/index.js:1:1 boot +legacy/app.js:9:3 FUNCTION_CALL extra -> ambiguous_unknown - +legacy/lib/util/trim.js:2:10 METHOD_CALL s.trim -> ambiguous_unknown - +legacy/webpack.config.cjs:7:13 METHOD_CALL path.resolve -> ambient_terminal - +legacy/webpack.config.cjs:8:13 METHOD_CALL path.join -> ambient_terminal - +mapped/probe.js:5:10 FUNCTION_CALL fmtDate -> known_edge mapped/own/date.js:1:1 fmtDate +shared/fmt.js:2:10 METHOD_CALL n.toFixed -> ambiguous_unknown - +src/pages/Home.js:10:3 FUNCTION_CALL scoped -> ambiguous_unknown - +src/pages/Home.js:11:10 FUNCTION_CALL fmtDate -> known_edge src/utils/date.js:1:1 fmtDate +src/pages/Home.js:9:3 FUNCTION_CALL fmtMoney -> known_edge shared/fmt.js:1:1 fmtMoney +src/utils/date.js:2:10 FUNCTION_CALL String -> ambient_terminal - +vite.config.js:10:18 FUNCTION_CALL fileURLToPath -> ambient_terminal - +vite.config.js:10:32 CONSTRUCTOR_CALL URL -> ambient_terminal - +vite.config.js:6:16 FUNCTION_CALL defineConfig -> ambiguous_unknown - +vite.config.js:6:16 FUNCTION_CALL defineConfig -> callback_registered vite.config.js:6:29 +vite.config.js:9:12 METHOD_CALL path.resolve -> ambient_terminal - diff --git a/graph/test/javascript/expected/71-bundler-config-alias.oracle b/graph/test/javascript/expected/71-bundler-config-alias.oracle new file mode 100644 index 00000000..2d722071 --- /dev/null +++ b/graph/test/javascript/expected/71-bundler-config-alias.oracle @@ -0,0 +1,8 @@ +legacy/webpack.config.cjs:7:13 METHOD_CALL resolve LIB_AMBIENT_OK +legacy/webpack.config.cjs:8:13 METHOD_CALL join LIB_AMBIENT_OK +src/utils/date.js:2:10 FUNCTION_CALL String LIB_AMBIENT_OK +vite.config.js:10:18 FUNCTION_CALL fileURLToPath LIB_AMBIENT_OK +vite.config.js:10:32 CONSTRUCTOR_CALL URL LIB_AMBIENT_OK +vite.config.js:6:16 FUNCTION_CALL defineConfig LIB_MISSED +vite.config.js:9:12 METHOD_CALL resolve LIB_AMBIENT_OK +# defects: 0 diff --git a/parser/src/parsers/javascript/bundler-alias-reader.ts b/parser/src/parsers/javascript/bundler-alias-reader.ts new file mode 100644 index 00000000..4ae2c174 --- /dev/null +++ b/parser/src/parsers/javascript/bundler-alias-reader.ts @@ -0,0 +1,196 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import * as ts from 'typescript'; + +/** + * The `resolve.alias` of a bundler config, as `compilerOptions.paths` would spell it. + * + * Vite and Vue apps usually have no `jsconfig.json`: `@` → `src` is declared only in + * `vite.config.js` (webpack apps do the same in `webpack.config.js`), so every + * `import … from '@/x'` was UNRESOLVED_MISSING and the call behind it "by name" (#1746). + * + * The config is read, never run. Only a replacement the syntax fixes is taken: + * `path.resolve(__dirname, 'src')` / `path.join(…)` / `resolve(…)`, + * `fileURLToPath(new URL('./src', import.meta.url))`, and a root-relative string + * (`'/src'`). A regular-expression `find`, a computed key or a replacement built from + * anything else is skipped, so the import stays unresolved rather than guessed. + */ +export type BundlerPaths = Record; + +export const BUNDLER_CONFIG_NAMES: readonly string[] = [ + 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', + 'vite.config.ts', 'vite.config.mts', 'vite.config.cts', + 'webpack.config.js', 'webpack.config.mjs', 'webpack.config.cjs', 'webpack.config.ts', +]; + +export function readBundlerAliases(configPath: string): BundlerPaths | undefined { + let text: string; + try { + text = fs.readFileSync(configPath, 'utf8'); + } catch { + return undefined; + } + const configDir = path.dirname(configPath); + const source = ts.createSourceFile(configPath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const paths: BundlerPaths = {}; + const visit = (node: ts.Node): void => { + if (ts.isPropertyAssignment(node) && propertyName(node.name) === 'alias' + && ts.isObjectLiteralExpression(node.parent) && ts.isPropertyAssignment(node.parent.parent) + && propertyName(node.parent.parent.name) === 'resolve') { + for (const [find, replacement] of aliasEntries(node.initializer, configDir)) { + addAlias(paths, find, replacement); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return Object.keys(paths).length === 0 ? undefined : paths; +} + +/** `{ '@': x }` or `[{ find: '@', replacement: x }]`: each string key with a fixed replacement. */ +function aliasEntries(value: ts.Expression, configDir: string): Array<[string, string]> { + const entries: Array<[string, string]> = []; + const take = (find: string | undefined, replacement: ts.Expression | undefined) => { + const target = replacement === undefined ? undefined : fixedPath(replacement, configDir); + if (find !== undefined && find !== '' && target !== undefined) { + entries.push([find, target]); + } + }; + if (ts.isObjectLiteralExpression(value)) { + for (const property of value.properties) { + if (ts.isPropertyAssignment(property)) { + take(propertyName(property.name), property.initializer); + } + } + } else if (ts.isArrayLiteralExpression(value)) { + for (const element of value.elements) { + if (!ts.isObjectLiteralExpression(element)) { + continue; + } + let find: string | undefined; + let replacement: ts.Expression | undefined; + for (const property of element.properties) { + if (!ts.isPropertyAssignment(property)) { + continue; + } + const key = propertyName(property.name); + if (key === 'find' && ts.isStringLiteralLike(property.initializer)) { + find = property.initializer.text; + } else if (key === 'replacement') { + replacement = property.initializer; + } + } + take(find, replacement); + } + } + return entries; +} + +/** + * Webpack's `key$` is an exact match only; any other key matches itself and `key/…`, + * which is the rule of both bundlers for a string `find`. + */ +function addAlias(paths: BundlerPaths, find: string, target: string): void { + if (find.endsWith('$')) { + paths[find.slice(0, -1)] ??= [target]; + return; + } + const key = find.endsWith('/') ? find.slice(0, -1) : find; + paths[key] ??= [target]; + paths[`${key}/*`] ??= [`${target}/*`]; +} + +/** An absolute directory the expression fixes, or undefined when it depends on anything else. */ +function fixedPath(expression: ts.Expression, configDir: string): string | undefined { + const node = unwrap(expression); + if (ts.isStringLiteralLike(node)) { + // A bare relative string is replaced textually and read from the IMPORTER, so it + // fixes no directory; a leading `/` is the project root in Vite. + return node.text.startsWith('/') ? path.join(configDir, node.text) : undefined; + } + if (ts.isCallExpression(node)) { + const callee = calleeName(node.expression); + // `require.resolve('pkg/x')` names a file inside a package, not a directory here. + const onRequire = ts.isPropertyAccessExpression(node.expression) + && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'require'; + if ((callee === 'resolve' || callee === 'join') && !onRequire) { + const segments: string[] = []; + for (const argument of node.arguments) { + const segment = segmentOf(argument, configDir); + if (segment === undefined) { + return undefined; + } + segments.push(segment); + } + return segments.length === 0 ? undefined : path.resolve(configDir, ...segments); + } + if (callee === 'fileURLToPath' && node.arguments.length === 1) { + return urlRelativeToConfig(node.arguments[0]!, configDir); + } + return undefined; + } + // `new URL('./src', import.meta.url).pathname` + if (ts.isPropertyAccessExpression(node) && node.name.text === 'pathname') { + return urlRelativeToConfig(node.expression, configDir); + } + return undefined; +} + +/** One argument of `path.resolve` / `path.join`: a string, `__dirname`, or `process.cwd()`. */ +function segmentOf(expression: ts.Expression, configDir: string): string | undefined { + const node = unwrap(expression); + if (ts.isStringLiteralLike(node)) { + return node.text; + } + if (ts.isIdentifier(node) && node.text === '__dirname') { + return configDir; + } + if (ts.isCallExpression(node) && node.arguments.length === 0 && calleeName(node.expression) === 'cwd') { + return configDir; + } + return undefined; +} + +/** `new URL('./src', import.meta.url)`: the first argument read from the config's own directory. */ +function urlRelativeToConfig(expression: ts.Expression, configDir: string): string | undefined { + const node = unwrap(expression); + if (!ts.isNewExpression(node) || calleeName(node.expression) !== 'URL' || node.arguments?.length !== 2) { + return undefined; + } + const [relative, base] = node.arguments; + if (!ts.isStringLiteralLike(relative!) || !isImportMetaUrl(unwrap(base!))) { + return undefined; + } + return relative.text.startsWith('/') ? undefined : path.resolve(configDir, relative.text); +} + +function isImportMetaUrl(node: ts.Node): boolean { + return ts.isPropertyAccessExpression(node) && node.name.text === 'url' + && ts.isMetaProperty(node.expression) && node.expression.keywordToken === ts.SyntaxKind.ImportKeyword; +} + +function calleeName(expression: ts.Expression): string | undefined { + if (ts.isIdentifier(expression)) { + return expression.text; + } + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text; + } + return undefined; +} + +function propertyName(name: ts.PropertyName): string | undefined { + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) { + return name.text; + } + return undefined; +} + +function unwrap(node: ts.Expression): ts.Expression { + let current = node; + while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current)) { + current = current.expression; + } + return current; +} diff --git a/parser/src/workflows/javascript/javascript-project-analyzer.ts b/parser/src/workflows/javascript/javascript-project-analyzer.ts index 1b465952..18e3e6ae 100644 --- a/parser/src/workflows/javascript/javascript-project-analyzer.ts +++ b/parser/src/workflows/javascript/javascript-project-analyzer.ts @@ -21,6 +21,7 @@ import { IrCompletenessReport, } from '@/parsers/javascript/extractors/js-ir-completeness'; import { moduleHashFor } from '@/parsers/javascript/extractors/js-module-extractor'; +import { BUNDLER_CONFIG_NAMES, readBundlerAliases } from '@/parsers/javascript/bundler-alias-reader'; import { PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; import { buildOutputDirectoriesNamedBy, @@ -633,36 +634,84 @@ type PathAliases = Partial(); + private readonly byDirectory = new Map(); + private readonly bundlerByDirectory = new Map(); aliasesFor(file: string): PathAliases { - return this.inDirectory(path.dirname(file)); + const directory = path.dirname(file); + const config = this.inDirectory(directory); + const bundler = this.bundlerInDirectory(directory); + // Both lie on one line of ancestors, so the longer directory is the nearer one. + if (bundler.aliases.paths === undefined) { + return config.aliases; + } + if (config.aliases.paths === undefined) { + return { ...config.aliases, ...bundler.aliases }; + } + return bundler.from.length > config.from.length ? bundler.aliases : config.aliases; } - private inDirectory(directory: string): PathAliases { - const cached = this.byDirectory.get(directory); + private bundlerInDirectory(directory: string): GoverningAliases { + return this.nearest(this.bundlerByDirectory, directory, (dir) => { + for (const name of BUNDLER_CONFIG_NAMES) { + const configPath = path.join(dir, name); + const paths = fs.existsSync(configPath) ? readBundlerAliases(configPath) : undefined; + if (paths !== undefined) { + return { paths, pathsBasePath: dir }; + } + } + return undefined; + }); + } + + private inDirectory(directory: string): GoverningAliases { + return this.nearest(this.byDirectory, directory, (dir) => { + for (const name of ['tsconfig.json', 'jsconfig.json']) { + const configPath = path.join(dir, name); + if (fs.existsSync(configPath)) { + return readPathAliases(configPath); + } + } + return undefined; + }); + } + + /** The first directory at or above `directory` where `read` finds a config, cached per directory. */ + private nearest( + cache: Map, + directory: string, + read: (dir: string) => PathAliases | undefined + ): GoverningAliases { + const cached = cache.get(directory); if (cached !== undefined) { return cached; } - let aliases: PathAliases | undefined; - for (const name of ['tsconfig.json', 'jsconfig.json']) { - const configPath = path.join(directory, name); - if (fs.existsSync(configPath)) { - aliases = readPathAliases(configPath); - break; - } - } - if (aliases === undefined) { + const here = read(directory); + let governing: GoverningAliases; + if (here !== undefined) { + governing = { aliases: here, from: directory }; + } else { const parent = path.dirname(directory); - aliases = parent === directory ? {} : this.inDirectory(parent); + governing = parent === directory ? { aliases: {}, from: '' } : this.nearest(cache, parent, read); } - this.byDirectory.set(directory, aliases); - return aliases; + cache.set(directory, governing); + return governing; } } +/** The aliases in force, and the directory of the config they came from ('' for none). */ +interface GoverningAliases { + aliases: PathAliases; + from: string; +} + function readPathAliases(configPath: string): PathAliases { const read = ts.readConfigFile(configPath, ts.sys.readFile); if (read.error !== undefined || read.config === undefined) {