From 81ab7b0f5193e3c25305350e655867e7cdca9a14 Mon Sep 17 00:00:00 2001 From: slugb0t Date: Wed, 3 Jun 2026 21:28:43 -0700 Subject: [PATCH] feat: :sparkles: resolve Nuxt/Nitro auto-imports in TypeScript scope resolver --- .../languages/typescript/nuxt-auto-imports.ts | 235 ++++++++++++++++++ .../languages/typescript/scope-resolver.ts | 128 +++++++++- 2 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts diff --git a/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts b/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts new file mode 100644 index 000000000..99bb92036 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts @@ -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; +} + +// ---- 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 { + const byLocalName = new Map(); + + 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, +): Promise { + 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, +): Promise { + 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 { + 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 { + 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 { + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index d4b2237dc..49a7532b3 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -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.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 = /(?(); + 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(); + const emittedCalls = new Set(); + + 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 };