feat: ✨ resolve Nuxt/Nitro auto-imports in TypeScript scope resolver

This commit is contained in:
slugb0t 2026-06-03 21:28:43 -07:00
parent 5cb00119e8
commit 81ab7b0f51
2 changed files with 358 additions and 5 deletions

View file

@ -0,0 +1,235 @@
/**
* Nuxt v4 auto-import resolution for the TypeScript scope-resolver.
*
* Nuxt and Nitro both make symbols available project-wide without explicit
* import statements. Two sources cover the common cases:
*
* 1. `.nuxt/imports.d.ts` - client/shared composables generated by Nuxt at
* build time. Only entries whose source is a project-local relative path
* (i.e. NOT a package in node_modules or a Nuxt runtime alias) are
* indexed. These are project utils and composables the developer wrote.
*
* 2. `server/utils/**` - Nitro auto-imports all exports from this directory
* tree into every `server/api/`, `server/routes/`, and
* `server/middleware/` file. These are not included in `imports.d.ts`
* because they are server-only.
*
* Both sources produce the same `NuxtAutoImportEntry` shape so the edge-
* emission pass can treat them uniformly.
*
* Detection: if `.nuxt/imports.d.ts` is absent the repo is not a Nuxt project
* and this module returns null immediately, adding zero overhead to non-Nuxt
* analysis runs. The `server/utils` scan is only attempted when the Nuxt
* detection succeeds, so it also costs nothing for non-Nuxt repos.
*
* Limitations:
* - Calls inside string literals or single-line comments are not excluded
* by the content scanner. False-positive edges are tagged with
* confidence 0.75 to signal that they are heuristic, not type-checked.
* - Re-exports with non-matching aliases (e.g. `flatUnwrap as unwrapSlot`
* where `flatUnwrap` is the graph node name) try both the original export
* name and the local alias when looking up the graph node.
* - Only direct exports are indexed; barrel re-exports that chain through
* multiple files are not followed.
*/
import fs from 'fs/promises';
import path from 'path';
// ---- types ------------------------------------------------------------------
/** A single auto-imported symbol and where it lives in the repo. */
export interface NuxtAutoImportEntry {
/** The name used at call sites (the alias when `export { X as Y }` form). */
readonly localName: string;
/** The original export name in the source file (before `as`). */
readonly exportName: string;
/** Repo-relative POSIX path to the source file (with extension). */
readonly sourceFile: string;
}
/**
* Aggregated auto-import map for one Nuxt workspace, keyed by the local
* name used in calling code.
*/
export interface NuxtAutoImportConfig {
readonly byLocalName: ReadonlyMap<string, NuxtAutoImportEntry>;
}
// ---- loader -----------------------------------------------------------------
/**
* Load the Nuxt auto-import map for `repoRoot`.
*
* Returns null when:
* - `.nuxt/imports.d.ts` does not exist (non-Nuxt project), or
* - the file exists but yields zero project-local entries.
*/
export async function loadNuxtAutoImports(repoRoot: string): Promise<NuxtAutoImportConfig | null> {
const byLocalName = new Map<string, NuxtAutoImportEntry>();
await collectImportsDts(repoRoot, byLocalName);
// Only attempt the server/utils scan when we confirmed this is a Nuxt project
// (imports.d.ts was present and populated the map, or at least the file existed).
const nuxtDirExists = await dirExists(path.join(repoRoot, '.nuxt'));
if (nuxtDirExists) {
await collectNitroServerUtils(repoRoot, byLocalName);
}
return byLocalName.size > 0 ? { byLocalName } : null;
}
// ---- .nuxt/imports.d.ts -----------------------------------------------------
/**
* Parse `.nuxt/imports.d.ts` and add project-local entries to `byLocalName`.
*
* The file contains lines of the form:
* export { name1, name2, origName as alias } from 'source'
*
* Only entries with a source that is a project-local relative path
* (starts with `./` or `../` AND does not contain `node_modules`) are
* included. Nuxt runtime paths (`#app/...`) and third-party packages are
* intentionally skipped because they have no graph nodes in the repo.
*/
async function collectImportsDts(
repoRoot: string,
byLocalName: Map<string, NuxtAutoImportEntry>,
): Promise<void> {
const importsPath = path.join(repoRoot, '.nuxt', 'imports.d.ts');
let content: string;
try {
content = await fs.readFile(importsPath, 'utf-8');
} catch {
return;
}
const nuxtDir = path.join(repoRoot, '.nuxt');
// Matches: export { name1, name2 as alias } from 'source'
const lineRe = /^export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/gm;
let m: RegExpExecArray | null;
while ((m = lineRe.exec(content)) !== null) {
const symbolsRaw = m[1]!;
const source = m[2]!;
if (!isProjectLocalPath(source)) continue;
const resolvedFile = await resolveExtension(path.resolve(nuxtDir, source));
if (resolvedFile === null) continue;
const sourceFile = toRepoPosix(repoRoot, resolvedFile);
for (const raw of symbolsRaw.split(',')) {
const trimmed = raw.trim();
if (!trimmed) continue;
const asIdx = trimmed.indexOf(' as ');
const exportName = asIdx >= 0 ? trimmed.slice(0, asIdx).trim() : trimmed;
const localName = asIdx >= 0 ? trimmed.slice(asIdx + 4).trim() : trimmed;
if (!localName || !exportName) continue;
if (!byLocalName.has(localName)) {
byLocalName.set(localName, { localName, exportName, sourceFile });
}
}
}
}
// ---- server/utils (Nitro auto-imports) --------------------------------------
/**
* Scan `server/utils/**` and register every exported symbol as a Nitro
* auto-import. Nitro makes all exports from this directory tree available
* without an import statement in server routes and middleware.
*/
async function collectNitroServerUtils(
repoRoot: string,
byLocalName: Map<string, NuxtAutoImportEntry>,
): Promise<void> {
const serverUtilsDir = path.join(repoRoot, 'server', 'utils');
if (!(await dirExists(serverUtilsDir))) return;
const tsFiles = await collectTsFiles(serverUtilsDir);
for (const absPath of tsFiles) {
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
const sourceFile = toRepoPosix(repoRoot, absPath);
// Match top-level exported functions and constants.
// Handles: export function X, export async function X, export const X =
// Does NOT attempt to match re-exports from other modules.
const exportRe =
/^export\s+(?:async\s+)?function\s+(\w+)|^export\s+const\s+(\w+)\s*[=:]/gm;
let m: RegExpExecArray | null;
while ((m = exportRe.exec(content)) !== null) {
const name = (m[1] ?? m[2])!;
if (!byLocalName.has(name)) {
byLocalName.set(name, { localName: name, exportName: name, sourceFile });
}
}
}
}
// ---- helpers ----------------------------------------------------------------
function isProjectLocalPath(source: string): boolean {
if (!source.startsWith('./') && !source.startsWith('../')) return false;
if (source.includes('node_modules')) return false;
return true;
}
async function resolveExtension(base: string): Promise<string | null> {
for (const ext of ['.ts', '.tsx', '.js', '.jsx', '']) {
const candidate = ext ? base + ext : base;
try {
await fs.access(candidate);
return candidate;
} catch {
// try next
}
}
return null;
}
function toRepoPosix(repoRoot: string, absPath: string): string {
return path.relative(repoRoot, absPath).replace(/\\/g, '/');
}
async function dirExists(dirPath: string): Promise<boolean> {
try {
const stat = await fs.stat(dirPath);
return stat.isDirectory();
} catch {
return false;
}
}
/** Recursively collect `.ts` and `.tsx` files under a directory. */
async function collectTsFiles(dir: string): Promise<string[]> {
const results: string[] = [];
let entries: import('fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...(await collectTsFiles(full)));
} else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
results.push(full);
}
}
return results;
}

View file

@ -14,9 +14,11 @@
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { generateId } from '../../../../lib/utils.js';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js';
import { typescriptProvider } from '../typescript.js';
import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js';
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
@ -26,10 +28,13 @@ import {
resolveTsTarget,
type TsResolveContext,
} from './index.js';
import { loadNuxtAutoImports, type NuxtAutoImportConfig } from './nuxt-auto-imports.js';
/** Shape the orchestrator threads in via `RunScopeResolutionInput.resolutionConfig`. */
interface TypescriptResolutionConfig {
readonly tsconfigPaths: TsconfigPaths | null;
/** Nuxt/Nitro auto-import map. Null for non-Nuxt projects. */
readonly nuxtAutoImports: NuxtAutoImportConfig | null;
}
/**
@ -92,11 +97,14 @@ const typescriptScopeResolver: ScopeResolver = {
resolveImportTarget: makeTsResolveImportTarget(),
// Threaded into `resolveImportTarget` so tsconfig path aliases
// (`@/services/user`, `~/x`, …) resolve through the same standard
// (`@/services/user`, `~/x`, ...) resolve through the same standard
// resolver branch the legacy DAG uses. One I/O round-trip per
// workspace pass; the orchestrator awaits this once.
// `nuxtAutoImports` is null for non-Nuxt projects (no .nuxt/imports.d.ts),
// so this adds zero overhead to ordinary TypeScript repos.
loadResolutionConfig: async (repoPath: string) => ({
tsconfigPaths: await loadTsconfigPaths(repoPath),
nuxtAutoImports: await loadNuxtAutoImports(repoPath),
}),
// TypeScript declaration merging + LEGB: local > import > wildcard,
@ -130,24 +138,134 @@ const typescriptScopeResolver: ScopeResolver = {
propagatesReturnTypesAcrossImports: true,
// TypeScript uses `.values()` / `.keys()` method-call syntax for
// collection views — no property-style accessors like C#'s
// collection views -- no property-style accessors like C#'s
// `Dictionary<K,V>.Values`. Leave `unwrapCollectionAccessor`
// undefined and let the regular member-call branch handle them.
//
// `collapseMemberCallsByCallerTarget` left undefined (= false) —
// `collapseMemberCallsByCallerTarget` left undefined (= false) --
// TypeScript legacy DAG emits one edge per call site, so
// per-site dedup is the parity target.
//
// `populateNamespaceSiblings` left undefined — TypeScript requires
// `populateNamespaceSiblings` left undefined -- TypeScript requires
// an explicit `import` / namespace augmentation for cross-file
// visibility; there's no implicit same-namespace sibling rule
// like C#'s.
//
// `hoistTypeBindingsToModule` — `tsBindingScopeFor` DOES hoist
// `hoistTypeBindingsToModule` -- `tsBindingScopeFor` DOES hoist
// method return-type bindings to the enclosing Module scope
// (mirrors C#), so enable the walk-up that lets the compound-
// receiver resolver find them.
hoistTypeBindingsToModule: true,
/**
* Emit CALLS edges for Nuxt/Nitro auto-imported symbols that are used
* without an explicit import statement.
*
* Nuxt makes composables and server utils available project-wide via its
* auto-import system. Because no `import` statement exists, the standard
* scope-resolution passes cannot create call-graph edges for these symbols.
* This hook recovers those edges after all normal resolution has run.
*
* For each TypeScript file the hook:
* 1. Builds the set of files already explicitly imported (to avoid
* creating duplicate edges for symbols imported conventionally).
* 2. Scans the raw source for identifier call-patterns (`name(`) and
* checks each against the auto-import map.
* 3. For each hit that is not already explicitly imported, emits a CALLS
* edge from the file's File node to the target function node, and an
* IMPORTS edge from the caller file to the source file (once per pair).
*
* Confidence is 0.75 (below the 0.9 used for fully resolved edges) to
* signal that these edges are heuristic: the content scanner does not
* filter string literals or comments.
*/
emitPostResolutionEdges(graph, parsedFiles, nodeLookup, indexes, ctx) {
const cfg = ctx.resolutionConfig as TypescriptResolutionConfig | undefined;
const autoImports = cfg?.nuxtAutoImports;
if (!autoImports || autoImports.byLocalName.size === 0) return;
// Pre-index: localName -> entry for fast lookup during content scan.
const { byLocalName } = autoImports;
// Regex matches bare identifier call sites: word-boundary + name + "(".
// Excludes `new X(` (constructor calls are not free-function auto-imports).
const CALL_RE = /(?<![.\w])([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
for (const parsedFile of parsedFiles) {
const { filePath } = parsedFile;
if (!filePath.endsWith('.ts') && !filePath.endsWith('.tsx')) continue;
const content = ctx.fileContents.get(filePath);
if (!content) continue;
// Collect files already brought in by an explicit import in this file.
const explicitImports = new Set<string>();
for (const [scopeId, edges] of indexes.imports) {
const scope = indexes.scopeTree.getScope(scopeId);
if (scope?.filePath !== filePath) continue;
for (const edge of edges) {
if (edge.targetFile !== null) explicitImports.add(edge.targetFile);
}
}
const fileId = generateId('File', filePath);
// Track (sourceFile) pairs already handled for this caller to avoid
// emitting duplicate IMPORTS edges and duplicate CALLS edges per symbol.
const emittedImports = new Set<string>();
const emittedCalls = new Set<string>();
CALL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = CALL_RE.exec(content)) !== null) {
const localName = m[1]!;
const entry = byLocalName.get(localName);
if (!entry) continue;
const { exportName, sourceFile } = entry;
// Skip when the file already has an explicit import from this source.
if (explicitImports.has(sourceFile)) continue;
// Emit one IMPORTS edge per (caller, sourceFile) pair.
if (!emittedImports.has(sourceFile)) {
emittedImports.add(sourceFile);
const targetFileId = generateId('File', sourceFile);
if (graph.getNode(targetFileId)) {
graph.addRelationship({
id: generateId('IMPORTS', `${fileId}->nuxt-auto-import->${targetFileId}`),
sourceId: fileId,
targetId: targetFileId,
type: 'IMPORTS',
confidence: 0.75,
reason: 'nuxt-auto-import-file',
});
}
}
// Emit one CALLS edge per (caller, symbol) pair.
const callKey = `${sourceFile}::${localName}`;
if (emittedCalls.has(callKey)) continue;
emittedCalls.add(callKey);
// Look up the graph node by export name first, fall back to local name.
// The fallback handles `default as X` where the function is named X.
const targetNodeId =
nodeLookup.get(simpleKey(sourceFile, exportName)) ??
(exportName !== localName ? nodeLookup.get(simpleKey(sourceFile, localName)) : undefined);
if (!targetNodeId || !graph.getNode(targetNodeId)) continue;
graph.addRelationship({
id: generateId('CALLS', `${fileId}:nuxt-auto-import:${localName}->${targetNodeId}`),
sourceId: fileId,
targetId: targetNodeId,
type: 'CALLS',
confidence: 0.75,
reason: 'nuxt-auto-import',
});
}
}
},
};
export { typescriptScopeResolver };