diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/app.js b/graph/test/javascript/cases/70-jsconfig-path-alias/src/app.js new file mode 100644 index 000000000..fbe17b85c --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/app.js @@ -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); +} diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/jsconfig.json b/graph/test/javascript/cases/70-jsconfig-path-alias/src/jsconfig.json new file mode 100644 index 000000000..32913d9f6 --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["./lib/*"] } + } +} diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/lib/check.js b/graph/test/javascript/cases/70-jsconfig-path-alias/src/lib/check.js new file mode 100644 index 000000000..3b925cb6a --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/lib/check.js @@ -0,0 +1,7 @@ +export function checkA(token) { + return token !== undefined; +} + +export function checkB(token) { + return token === "ok"; +} diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/jsconfig.json b/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/jsconfig.json new file mode 100644 index 000000000..f396c3895 --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/jsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "checkJs": true } } diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/probe.js b/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/probe.js new file mode 100644 index 000000000..73aee1d3d --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/probe.js @@ -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); +} diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/package.json b/graph/test/javascript/cases/70-jsconfig-path-alias/src/package.json new file mode 100644 index 000000000..cef1ebd64 --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/package.json @@ -0,0 +1 @@ +{ "name": "path-alias-fixture", "type": "module" } diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/models/note.server.js b/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/models/note.server.js new file mode 100644 index 000000000..809417323 --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/models/note.server.js @@ -0,0 +1,3 @@ +export function getNote(id) { + return { id }; +} diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/route.js b/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/route.js new file mode 100644 index 000000000..0821ece56 --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/route.js @@ -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); +} diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/tsconfig.json b/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/tsconfig.json new file mode 100644 index 000000000..60cfcb5d6 --- /dev/null +++ b/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "paths": { "~/*": ["./app/*"] } + } +} diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.diag b/graph/test/javascript/expected/70-jsconfig-path-alias.diag new file mode 100644 index 000000000..74d4aa41c --- /dev/null +++ b/graph/test/javascript/expected/70-jsconfig-path-alias.diag @@ -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 diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.edges b/graph/test/javascript/expected/70-jsconfig-path-alias.edges new file mode 100644 index 000000000..09a8c77c8 --- /dev/null +++ b/graph/test/javascript/expected/70-jsconfig-path-alias.edges @@ -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 diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.oracle b/graph/test/javascript/expected/70-jsconfig-path-alias.oracle new file mode 100644 index 000000000..16df3e532 --- /dev/null +++ b/graph/test/javascript/expected/70-jsconfig-path-alias.oracle @@ -0,0 +1,2 @@ +app.js:11:10 FUNCTION_CALL checkB EXACT lib/check.js:5:1 +# defects: 0 diff --git a/parser/src/test/javascript-tests.ts b/parser/src/test/javascript-tests.ts index 9ba5e5d42..32146004f 100644 --- a/parser/src/test/javascript-tests.ts +++ b/parser/src/test/javascript-tests.ts @@ -3184,6 +3184,61 @@ async function packageSpecifiersResolveUnderTheSiteConditions(): Promise 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 { + 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. * @@ -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 }, diff --git a/parser/src/workflows/javascript/javascript-project-analyzer.ts b/parser/src/workflows/javascript/javascript-project-analyzer.ts index 5ee34e3fe..867a2f0bc 100644 --- a/parser/src/workflows/javascript/javascript-project-analyzer.ts +++ b/parser/src/workflows/javascript/javascript-project-analyzer.ts @@ -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. @@ -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, }); @@ -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>; + +/** + * 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(); + + 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`. *