Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function extra() {
return 2;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function boot() {
return 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function trim(s) {
return s.trim();
}
Original file line number Diff line number Diff line change
@@ -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',
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./own/*"] } } }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function fmtDate(d) {
return `own ${d}`;
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "bundler-alias-fixture", "type": "module" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function fmtMoney(n) {
return n.toFixed(2);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function fmtDate(d) {
return String(d);
}
Original file line number Diff line number Diff line change
@@ -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)),
},
},
}));
21 changes: 21 additions & 0 deletions graph/test/javascript/expected/71-bundler-config-alias.diag
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions graph/test/javascript/expected/71-bundler-config-alias.edges
Original file line number Diff line number Diff line change
@@ -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 <arrow>
vite.config.js:9:12 METHOD_CALL path.resolve -> ambient_terminal -
Original file line number Diff line number Diff line change
@@ -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
196 changes: 196 additions & 0 deletions parser/src/parsers/javascript/bundler-alias-reader.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;

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;
}
Loading
Loading