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
12 changes: 12 additions & 0 deletions graph/test/javascript/cases/70-jsconfig-path-alias/src/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// an alias import through jsconfig paths: resolved to lib/check.js
import { checkA } from '@/check';
// the relative spelling of the same module: unchanged by the mapping
import { checkB } from './lib/check.js';

export function probeA(request) {
return checkA(request.token);
}

export function probeB(request) {
return checkB(request.token);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./lib/*"] }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function checkA(token) {
return token !== undefined;
}

export function checkB(token) {
return token === "ok";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "compilerOptions": { "checkJs": true } }
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// CONTROL: the nearest jsconfig.json maps nothing, so '@/check' does not resolve here
import { checkA } from '@/check';

export function probeC(request) {
return checkA(request.token);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "path-alias-fixture", "type": "module" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getNote(id) {
return { id };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// paths without baseUrl, from a tsconfig.json nearer than the root jsconfig.json
import { getNote } from '~/models/note.server';

export async function loader({ params }) {
return getNote(params.noteId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"paths": { "~/*": ["./app/*"] }
}
}
3 changes: 3 additions & 0 deletions graph/test/javascript/expected/70-jsconfig-path-alias.diag
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import_cause noalias/probe.js:2:10 @/check not_staged
package_entry path-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> -
unresolved noalias/probe.js:5:10 FUNCTION_CALL checkA callee_untyped
4 changes: 4 additions & 0 deletions graph/test/javascript/expected/70-jsconfig-path-alias.edges
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
app.js:11:10 FUNCTION_CALL checkB -> known_edge lib/check.js:5:1 checkB
app.js:7:10 FUNCTION_CALL checkA -> known_edge lib/check.js:1:1 checkA
noalias/probe.js:5:10 FUNCTION_CALL checkA -> ambiguous_unknown -
remix/app/route.js:5:10 FUNCTION_CALL getNote -> known_edge remix/app/models/note.server.js:1:1 getNote
2 changes: 2 additions & 0 deletions graph/test/javascript/expected/70-jsconfig-path-alias.oracle
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
app.js:11:10 FUNCTION_CALL checkB EXACT lib/check.js:5:1
# defects: 0
56 changes: 56 additions & 0 deletions parser/src/test/javascript-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3184,6 +3184,61 @@ async function packageSpecifiersResolveUnderTheSiteConditions(): Promise<number>
return failures;
}

/**
* A `compilerOptions.paths` alias resolves to the project file it maps to.
*
* `@/lib/x` (a jsconfig `baseUrl` + `paths`) and `~/models/x` (a tsconfig `paths`
* alone, `extends`-free) are how Next.js, Remix, Nuxt and Vite projects import their
* own code; without the mapping each was UNRESOLVED_MISSING and the call "by name".
* The CONTROL is a directory whose nearest jsconfig maps nothing: its `@/check` stays
* unresolved, as the bundler would refuse it too, and a relative import is unchanged.
*/
async function pathAliasesResolveThroughTheNearestConfig(): Promise<number> {
let failures = 0;
const root = scratchDir('js-gate-paths-');
const out = scratchDir('js-gate-paths-out-');
const write = (rel: string, text: string) => {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), text);
};
write('package.json', '{ "name": "paths-fixture", "type": "module" }');
write('jsconfig.json', '{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }');
write('src/lib/check.js', 'export function checkA() { return 1; }\n');
write('src/app.js', "import { checkA } from '@/lib/check'; import { checkA as b } from './lib/check.js';\n");
write('remix/tsconfig.json', '{ "compilerOptions": { "paths": { "~/*": ["./app/*"] } } }');
write('remix/app/models/note.server.js', 'export function getNote() { return 1; }\n');
write('remix/app/route.js', "import { getNote } from '~/models/note.server';\n");
write('plain/jsconfig.json', '{ "compilerOptions": { "checkJs": true } }');
write('plain/probe.js', "import { checkA } from '@/lib/check';\n");
await new JavaScriptProjectAnalyzer().analyzeAll([root], { outputDir: out, baseMservPath: root, serviceVersionLink: 'gate-v1' });
const relations = readRelations(out);
const imports = relations.find((r) => r.name === 'js_import');
const modules = relations.find((r) => r.name === 'js_module');
if (imports === undefined || modules === undefined || imports.header.length === 0) {
return fail('the paths fixture produced no js_import rows');
}
const fileOf = new Map(modules.rows.map((row) => [row[pkIndexOf(modules.header, 'js_module')] ?? '', row[modules.header.indexOf('filePath')] ?? '']));
const spec = imports.header.indexOf('specifier'), owner = imports.header.indexOf('ownerModuleLinkHash');
const resolved = imports.header.indexOf('resolvedFilePath'), outcome = imports.header.indexOf('resolutionOutcome');
const want: Array<[string, string, string, string]> = [
['src/app.js', '@/lib/check', 'RESOLVED_PROJECT', 'src/lib/check'],
['src/app.js', './lib/check.js', 'RESOLVED_PROJECT', 'src/lib/check'],
['remix/app/route.js', '~/models/note.server', 'RESOLVED_PROJECT', 'remix/app/models/note.server'],
['plain/probe.js', '@/lib/check', 'UNRESOLVED_MISSING', ''],
];
for (const [file, specifier, wantOutcome, target] of want) {
const rows = imports.rows.filter((row) => fileOf.get(row[owner] ?? '') === file && row[spec] === specifier);
if (rows.length === 0) { failures += fail(`${file}: no js_import row for '${specifier}'`); continue; }
for (const row of rows) {
const got = (row[resolved] ?? '').replace(/\\/g, '/');
if (row[outcome] !== wantOutcome || got !== target) {
failures += fail(`${file} '${specifier}': ${row[outcome]} '${got}', want ${wantOutcome} '${target}'`);
}
}
}
return failures;
}

/**
* A member declared under a computed name links its KEY, and a literal key names it.
*
Expand Down Expand Up @@ -7187,6 +7242,7 @@ const CHECKS: Check[] = [
{ name: 'module-edge 1:1', proves: 'every module-edge expression is pointed at by exactly one import or export, so the second pass cannot double-mint', run: moduleEdgeOneToOne },
{ name: 'call-site 1:1', proves: 'one call site per call-like expression, and require() has none because it is a module edge', run: callSiteOneToOne },
{ name: 'package specifiers resolve under the site conditions', proves: "a require() of an exports-only package resolves to its `require` build and an ES import to its `import` build, a subpath export and a main-only package either way, so a dual package links to the build the runtime loads (#601)", run: packageSpecifiersResolveUnderTheSiteConditions },
{ name: 'path aliases resolve through the nearest config', proves: "an import through a jsconfig/tsconfig `paths` alias ('@/lib/x', '~/models/x') resolves to the project file it maps to, and a directory whose nearest config maps nothing leaves the alias unresolved", run: pathAliasesResolveThroughTheNearestConfig },
{ name: 'computed member names link their key', proves: "a member declared under a computed name links its key expression (rooted COMPUTED_NAME, bound to the key's const) and a literal key fills name, on class methods, a getter, class fields, object-literal and prototype-literal members (#598)", run: computedMemberNamesLinkTheirKey },
{ name: 'pattern binding defaults are linked', proves: "a reference to a binding declared inside a destructuring pattern with a default links the default's root expression (c35), for object, nested and array parameter patterns and a variable pattern; a top-level parameter default and a binding without one link nothing (#673)", run: patternBindingDefaultsAreLinked },
{ name: 'several roots produce one set', proves: 'overlapping discovered roots merge into one flat fact base with no file extracted twice — the failure the real entry point found and a single-root harness cannot', run: severalRootsProduceOneSet },
Expand Down
72 changes: 70 additions & 2 deletions parser/src/workflows/javascript/javascript-project-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export class JavaScriptProjectAnalyzer {
? '' : realPathOf(path.resolve(options.baseMservPath));
const pathAnchor = pathAnchorFor(rootDir, baseMservPath);
const packageJson = new PackageJsonResolver();
const pathAliases = new PathAliasResolver();
// The union of every root's files, by absolute path. A monorepo root and its
// packages both claim the same files, and extracting one twice would mint
// identical primary keys and DOUBLE the row count rather than colliding.
Expand Down Expand Up @@ -444,7 +445,7 @@ export class JavaScriptProjectAnalyzer {
? ''
: toRelative(pathAnchor, governing.packageJsonPath),
packageName: governing.packageName,
compilerOptions: compilerOptionsFor(governing.moduleSystem),
compilerOptions: compilerOptionsFor(governing.moduleSystem, pathAliases.aliasesFor(file)),
projectModuleHashes,
toProjectRelative,
});
Expand Down Expand Up @@ -597,15 +598,82 @@ export class JavaScriptProjectAnalyzer {
* here. A parser running two resolvers and comparing them is doing resolution
* work, which is exactly what `js_import.resolverAgreement` was deleted for.
*/
function compilerOptionsFor(moduleSystem: string): ts.CompilerOptions {
function compilerOptionsFor(moduleSystem: string, aliases: PathAliases): ts.CompilerOptions {
return {
allowJs: true,
target: ts.ScriptTarget.ESNext,
module: moduleSystem === 'ESM' ? ts.ModuleKind.NodeNext : ts.ModuleKind.CommonJS,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
...aliases,
};
}

/** The alias half of a `jsconfig.json` / `tsconfig.json`: nothing else of it is read. */
type PathAliases = Partial<Pick<ts.CompilerOptions, 'baseUrl' | 'paths' | 'pathsBasePath'>>;

/**
* The `compilerOptions.paths` / `baseUrl` that govern a file, from the nearest
* `tsconfig.json` or `jsconfig.json` above it (`tsconfig.json` first, as editors do).
*
* Next.js, Remix, Nuxt and Vite projects import their own code as `@/lib/x` or
* `~/models/x` through that mapping, and the bundler honours it. Without it every
* such import was UNRESOLVED_MISSING, so a plain imported function was called
* "by name". `extends` is followed by `ts.parseJsonConfigFileContent`; the directory
* listing it would do for `include` is skipped, since only the options are wanted.
*/
class PathAliasResolver {
private readonly byDirectory = new Map<string, PathAliases>();

aliasesFor(file: string): PathAliases {
return this.inDirectory(path.dirname(file));
}

private inDirectory(directory: string): PathAliases {
const cached = this.byDirectory.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 parent = path.dirname(directory);
aliases = parent === directory ? {} : this.inDirectory(parent);
}
this.byDirectory.set(directory, aliases);
return aliases;
}
}

function readPathAliases(configPath: string): PathAliases {
const read = ts.readConfigFile(configPath, ts.sys.readFile);
if (read.error !== undefined || read.config === undefined) {
return {};
}
const host: ts.ParseConfigHost = {
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
readDirectory: () => [],
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
};
const { options } = ts.parseJsonConfigFileContent(read.config, host, path.dirname(configPath),
undefined, configPath);
const aliases: PathAliases = {};
if (options.baseUrl !== undefined) {
aliases.baseUrl = options.baseUrl;
}
if (options.paths !== undefined) {
aliases.paths = options.paths;
aliases.pathsBasePath = options.pathsBasePath;
}
return aliases;
}

/**
* Every emitted path hangs off this, not off `rootDir`.
*
Expand Down
Loading