feat: ✨ resolve Nuxt/Nitro auto-imports in TypeScript scope resolver (#2026)

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

* fix: 🐛 skip self-referential edges in Nuxt auto-import emission

* fix: 🐛 address Sourcery review -- gate Nitro scan on imports.d.ts and pre-index explicit imports

* fix: scope Nuxt auto-import resolution

* fix: address Nuxt auto-import review follow-ups

* fix(ingestion): capture only LHS binding names in Nitro server-util exports

The Nuxt server-util export scanner ran a declarator regex over the whole
`export const …` right-hand side, so it registered RHS tokens as auto-import
names: arrow-function parameters (`export const f = (event) => …` → `event`),
object-literal keys (`export const c = { onError } ` → `onError`), and bare
operands. It also dropped generic-typed declarators
(`export const x: Map<a, b> = …`) because the type-annotation skip broke at the
comma inside the generic. Both produced wrong/missing auto-import CALLS edges.

Capture only the leading binding name of each top-level declarator via a
depth-aware comma splitter (tracks (), [], {}, <>), skipping destructuring
patterns. Nitro auto-imports only surface top-level binding names, so the RHS
is never parsed. Adds unit coverage for the param/object-key/operand/generic
and multi-declarator forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU

* fix(ingestion): stop Nitro server callers resolving client composables

`getNuxtAutoImportEntry` fell back to the client composable map when a
`server/api|routes|middleware` caller's name had no `server/utils` entry. But
Nitro only auto-imports `server/utils/**` into the server context — app
`composables/` are Vue-app-only — so that fallback minted CALLS/IMPORTS edges
Nitro never creates (e.g. a server route "calling" a composable it cannot see
without an explicit import).

Server callers now resolve the server map only. Restructure the barrel-directory
integration test to use a client caller (which legitimately auto-imports the
composable) so `index.*` resolution stays covered, and add a negative assertion
that `server/api/route.ts` emits no edge to `composables/*` while its real
`server/utils` call still resolves. Unit test locks that a server caller does
not fall back to a client-only name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU

* fix(ingestion): let unresolved explicit imports shadow Nuxt auto-imports

The explicit-import suppression index only recorded import local names whose
edge resolved to a file (`edge.targetFile !== null`). An explicit import from an
unresolved external package — `import { useAuto } from '@vueuse/core'; useAuto()`
— therefore escaped suppression, and the post-resolution hook emitted a spurious
Nuxt auto-import CALLS edge for a name the file already imports explicitly.

Record the local name regardless of whether the import resolved: an explicit
import is authoritative shadowing intent. Adds an integration fixture importing
from an external package and a (non-vacuous) assertion that it emits no nuxt
edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU

* fix(ingestion): let type-annotated params shadow Nuxt auto-imports

hasLocalBindingInScopeChain only consulted scope.bindings, but type-annotated
function parameters live in scope.typeBindings (the TS scope query records them
as `@type-binding.parameter`, not `@declaration`). A parameter named like a
composable therefore failed to suppress the auto-import, leaking a spurious
CALLS edge.

Also check scope.typeBindings for the name (same-file scopes only). typeBindings
holds value-space binders' type facts (parameter annotations, `self`, variable
annotations) and never a pure type that belongs to callable space, so this
cannot over-suppress a real auto-import.

Documents the residual: function-typed params (`p: () => void`), untyped params,
destructured locals, and catch-clause vars are captured by neither map and still
leak — closing that needs shared scope-query changes beyond this feature, left
as a follow-up. Also adds a no-vacuous-pass guard to the shadowing/noise test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU

* fix(ingestion): treat server/plugins and server/tasks as Nitro runtime

isNitroServerRuntimeFile only matched server/api, server/routes, and
server/middleware. Nitro also auto-imports server/utils into server/plugins
and (since Nitro 2.6) server/tasks, so callers there were misrouted to the
client composable map. Extend the prefix set (now a named constant) to cover
them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU

* fix(ingestion): merge duplicate JSDoc on collectImportsDts

Two consecutive JSDoc blocks preceded collectImportsDts; tooling (IDEs,
TypeDoc) attaches only the last one, silently dropping the descriptive block.
Fold the "returns true when read" line into the descriptive block as a
`@returns` tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU

* fix(ingestion): contain .nuxt/imports.d.ts source resolution to the repo

A crafted `.nuxt/imports.d.ts` source such as `from '../../../../etc/passwd'`
passes the project-local relative-path check but resolves outside the analyzed
repo, causing fs.stat probes against arbitrary host paths. Skip any source that
resolves outside repoRoot before touching the filesystem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian Portillo 2026-06-24 04:32:51 -07:00 • committed by GitHub
parent 2984b5826e
commit 385cb78c5f
19 changed files with 1017 additions and 6 deletions

View file

@ -0,0 +1,377 @@
/**
* 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.
*
* The two sources are kept in separate maps because their visibility scopes
* differ: app/client files do not see `server/utils`, while Nitro server
* handlers prefer same-named server utilities over client composables.
*
* 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:
* - Auto-import edges are still heuristic rather than type-checked. The
* emitter tags them with confidence 0.75.
* - 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 ------------------------------------------------------------------
export type NuxtAutoImportScope = 'client' | 'server';
/** 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;
/** Which Nuxt/Nitro visibility scope supplied this entry. */
readonly scope: NuxtAutoImportScope;
}
/**
* Aggregated auto-import maps for one Nuxt workspace, keyed by the local
* name used in calling code. Client/shared entries come from
* `.nuxt/imports.d.ts`; server entries come from `server/utils`.
*/
export interface NuxtAutoImportConfig {
readonly clientByLocalName: ReadonlyMap<string, NuxtAutoImportEntry>;
readonly serverByLocalName: ReadonlyMap<string, NuxtAutoImportEntry>;
}
const FILE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'] as const;
const GENERATED_DIR_NAMES = new Set([
'.git',
'.nuxt',
'.output',
'.next',
'build',
'coverage',
'dist',
'node_modules',
]);
const IMPORTS_DTS_EXPORT_RE = /^export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/gm;
const NITRO_DECLARATION_EXPORT_RE =
/^export\s+(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)|^export\s+(?:default\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)/gm;
const NITRO_VARIABLE_EXPORT_RE = /^export\s+(?:const|let|var)\s+([^;\n]+)/gm;
// Matches the binding name at the head of a single declarator (`name`, `name:`, `name =`).
// A leading `{`/`[` (destructuring) does not match, so destructured exports are skipped.
const DECLARATOR_NAME_RE = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)/;
// ---- loader -----------------------------------------------------------------
/**
* Load the Nuxt auto-import map for `repoRoot`.
*
* Returns null when:
* - `.nuxt/imports.d.ts` does not exist (non-Nuxt project or pre-build), or
* - the file exists but yields zero project-local entries and `server/utils`
* is absent or empty.
*
* The `server/utils` scan is only attempted when `.nuxt/imports.d.ts` was
* successfully read, confirming this is an initialized Nuxt project. This
* avoids partial results from repos that have a `server/utils` directory but
* are not Nuxt projects.
*/
export async function loadNuxtAutoImports(repoRoot: string): Promise<NuxtAutoImportConfig | null> {
const clientByLocalName = new Map<string, NuxtAutoImportEntry>();
const serverByLocalName = new Map<string, NuxtAutoImportEntry>();
const nuxtInitialized = await collectImportsDts(repoRoot, clientByLocalName);
// Only scan server/utils when imports.d.ts was present, confirming this is
// an initialized Nuxt project. Without this gate, a non-Nuxt repo with a
// server/utils directory would get spurious Nitro auto-import edges.
if (nuxtInitialized) {
await collectNitroServerUtils(repoRoot, serverByLocalName);
}
const config = { clientByLocalName, serverByLocalName };
return hasNuxtAutoImports(config) ? config : null;
}
export function hasNuxtAutoImports(config: NuxtAutoImportConfig): boolean {
return config.clientByLocalName.size > 0 || config.serverByLocalName.size > 0;
}
export function getNuxtAutoImportEntry(
config: NuxtAutoImportConfig,
localName: string,
callerFile: string,
): NuxtAutoImportEntry | undefined {
if (isNitroServerRuntimeFile(callerFile)) {
// Nitro auto-imports only `server/utils/**` into the server context; app
// `composables/` are Vue-app-only. Server callers therefore resolve the
// server map only — no client fallback, which would emit cross-context
// CALLS edges Nitro never actually creates.
return config.serverByLocalName.get(localName);
}
return config.clientByLocalName.get(localName);
}
/**
* Directories whose files run in the Nitro server context and so receive
* `server/utils` auto-imports without an explicit import: API routes, server
* routes, middleware, plugins, and (Nitro 2.6+) tasks.
*/
const NITRO_RUNTIME_DIR_PREFIXES = [
'server/api/',
'server/routes/',
'server/middleware/',
'server/plugins/',
'server/tasks/',
] as const;
export function isNitroServerRuntimeFile(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/');
return NITRO_RUNTIME_DIR_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
// ---- .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.
*
* @returns true when `.nuxt/imports.d.ts` was successfully read.
*/
async function collectImportsDts(
repoRoot: string,
byLocalName: Map<string, NuxtAutoImportEntry>,
): Promise<boolean> {
const importsPath = path.join(repoRoot, '.nuxt', 'imports.d.ts');
let content: string;
try {
content = await fs.readFile(importsPath, 'utf-8');
} catch {
return false;
}
const nuxtDir = path.join(repoRoot, '.nuxt');
IMPORTS_DTS_EXPORT_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = IMPORTS_DTS_EXPORT_RE.exec(content)) !== null) {
const symbolsRaw = m[1]!;
const source = m[2]!;
if (!isProjectLocalPath(source)) continue;
// Containment guard: a crafted source (`from '../../../../etc/passwd'`)
// passes the relative-path check but escapes the repo. Skip anything that
// resolves outside repoRoot before touching the filesystem.
const resolvedBase = path.resolve(nuxtDir, source);
if (!isWithinRepo(repoRoot, resolvedBase)) continue;
const resolvedFile = await resolveExtension(resolvedBase);
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, scope: 'client' });
}
}
}
return true;
}
// ---- 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);
for (const name of extractNitroExportNames(content)) {
if (!byLocalName.has(name)) {
byLocalName.set(name, { localName: name, exportName: name, sourceFile, scope: 'server' });
}
}
}
}
// ---- helpers ----------------------------------------------------------------
function isProjectLocalPath(source: string): boolean {
if (!source.startsWith('./') && !source.startsWith('../')) return false;
if (source.includes('node_modules')) return false;
return true;
}
/** True when `absPath` is `repoRoot` itself or lives beneath it. */
function isWithinRepo(repoRoot: string, absPath: string): boolean {
const root = path.resolve(repoRoot);
return absPath === root || absPath.startsWith(root + path.sep);
}
async function resolveExtension(base: string): Promise<string | null> {
const directFile = await firstExistingFile([...FILE_EXTENSIONS.map((ext) => base + ext), base]);
if (directFile !== null) return directFile;
return firstExistingFile(FILE_EXTENSIONS.map((ext) => path.join(base, `index${ext}`)));
}
async function firstExistingFile(candidates: readonly string[]): Promise<string | null> {
const matches = await Promise.all(
candidates.map(async (candidate) => ((await isFile(candidate)) ? candidate : null)),
);
return matches.find((candidate): candidate is string => candidate !== null) ?? null;
}
async function isFile(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch {
return false;
}
}
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 supported TypeScript/JavaScript 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) {
if (entry.isDirectory() && GENERATED_DIR_NAMES.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...(await collectTsFiles(full)));
} else if (entry.isFile() && hasSupportedSourceExtension(entry.name)) {
results.push(full);
}
}
return results;
}
function hasSupportedSourceExtension(fileName: string): boolean {
return FILE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
}
function extractNitroExportNames(content: string): string[] {
const names = new Set<string>();
NITRO_DECLARATION_EXPORT_RE.lastIndex = 0;
let declaration: RegExpExecArray | null;
while ((declaration = NITRO_DECLARATION_EXPORT_RE.exec(content)) !== null) {
const name = declaration[1] ?? declaration[2];
if (name) names.add(name);
}
NITRO_VARIABLE_EXPORT_RE.lastIndex = 0;
let variableDeclaration: RegExpExecArray | null;
while ((variableDeclaration = NITRO_VARIABLE_EXPORT_RE.exec(content)) !== null) {
// Only the LHS binding name of each top-level declarator is a Nitro export.
// Splitting on top-level commas and reading the leading identifier avoids
// capturing RHS tokens (arrow params, object keys, operands) as export names,
// and tolerates commas inside generic type annotations (`x: Map<a, b> = …`).
for (const declarator of splitTopLevelDeclarators(variableDeclaration[1]!)) {
const name = DECLARATOR_NAME_RE.exec(declarator);
if (name) names.add(name[1]!);
}
}
return [...names];
}
/**
* Split a `const`/`let`/`var` declarator list on top-level commas, tracking
* `()`, `[]`, `{}`, and `<>` nesting so commas inside call args, object/array
* literals, and generic type arguments do not split a declarator. Errs toward
* under-splitting on pathological RHS (a missed binding name, never a spurious
* one) — Nitro only auto-imports real top-level binding names.
*/
function splitTopLevelDeclarators(text: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '(' || ch === '[' || ch === '{' || ch === '<') {
depth++;
} else if (ch === ')' || ch === ']' || ch === '}' || ch === '>') {
if (depth > 0) depth--;
} else if (ch === ',' && depth === 0) {
parts.push(text.slice(start, i));
start = i + 1;
}
}
parts.push(text.slice(start));
return parts;
}

View file

@ -12,11 +12,14 @@
* ./query.ts (TYPESCRIPT_SCOPE_QUERY constant).
*/
import type { ParsedFile } from 'gitnexus-shared';
import type { NodeLabel, ParsedFile, ScopeId } 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 type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { typescriptProvider } from '../typescript.js';
import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js';
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
@ -26,12 +29,30 @@ import {
resolveTsTarget,
type TsResolveContext,
} from './index.js';
import {
getNuxtAutoImportEntry,
hasNuxtAutoImports,
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;
}
const TYPESCRIPT_TYPE_ONLY_BINDING_TYPES = new Set<NodeLabel>([
'Interface',
'Type',
'TypeAlias',
'Typedef',
'Trait',
'Annotation',
'Decorator',
]);
/**
* Build a `resolveImportTarget` adapter that memoizes the workspace
* file list, the lower-cased file list, and the per-pass `resolveCache`
@ -92,11 +113,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 +154,198 @@ 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. Iterates parsed free-call reference sites and checks each against the
* auto-import map selected for the caller's Nuxt/Nitro scope.
* 3. For each hit that is not shadowed by a local binding or explicit
* import, 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 rather than type-checked.
*/
emitPostResolutionEdges(graph, parsedFiles, nodeLookup, indexes, ctx) {
const cfg = ctx.resolutionConfig as TypescriptResolutionConfig | undefined;
const autoImports = cfg?.nuxtAutoImports;
if (!autoImports || !hasNuxtAutoImports(autoImports)) return;
// Pre-build a file -> explicit imported local names index so importing one
// symbol from a source does not suppress other auto-imported symbols from it.
const explicitImportNamesByFile = new Map<string, Set<string>>();
for (const [scopeId, edges] of indexes.imports) {
const scope = indexes.scopeTree.getScope(scopeId);
if (!scope?.filePath) continue;
let names = explicitImportNamesByFile.get(scope.filePath);
if (!names) {
names = new Set<string>();
explicitImportNamesByFile.set(scope.filePath, names);
}
for (const edge of edges) {
// Record the local name whether or not the import resolved to a file.
// An explicit import of a name — even from an unresolved external
// package (`import { useAuto } from '@vueuse/core'`) — is authoritative
// shadowing intent and must suppress the auto-import for that name.
if (edge.localName) names.add(edge.localName);
}
}
for (const parsedFile of parsedFiles) {
const { filePath } = parsedFile;
if (!filePath.endsWith('.ts') && !filePath.endsWith('.tsx')) continue;
const fileId = generateId('File', filePath);
const explicitImports = explicitImportNamesByFile.get(filePath) ?? new Set<string>();
// 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>();
for (const site of parsedFile.referenceSites) {
if (site.kind !== 'call' || site.callForm !== 'free') continue;
const localName = site.name;
const entry = getNuxtAutoImportEntry(autoImports, localName, filePath);
if (!entry) continue;
const { exportName, sourceFile } = entry;
// Skip when the file already binds this name explicitly, when the file
// IS the source, or when a lexical same-file binding shadows it.
if (
explicitImports.has(localName) ||
sourceFile === filePath ||
hasLocalBindingInScopeChain(site.inScope, localName, filePath, indexes)
) {
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',
});
}
}
},
};
function occupiesTypeScriptValueSpace(type: NodeLabel): boolean {
return !TYPESCRIPT_TYPE_ONLY_BINDING_TYPES.has(type);
}
function hasLocalBindingInScopeChain(
scopeId: ScopeId,
name: string,
filePath: string,
indexes: ScopeResolutionIndexes,
): boolean {
const visited = new Set<ScopeId>();
let cursor: ScopeId | null | undefined = scopeId;
while (cursor !== null && cursor !== undefined && !visited.has(cursor)) {
visited.add(cursor);
const scope = indexes.scopeTree.getScope(cursor);
if (!scope) return false;
const localBindings = scope.bindings.get(name);
if (
localBindings?.some(
(binding) =>
binding.origin === 'local' &&
binding.def.filePath === filePath &&
occupiesTypeScriptValueSpace(binding.def.type),
)
) {
return true;
}
// Type-annotated function parameters — and other value-space type facts
// such as `self` and variable annotations — live in `scope.typeBindings`,
// not `scope.bindings`. A parameter named like a composable genuinely
// shadows the auto-import, and typeBindings never holds a pure type that
// belongs to callable space, so a same-file presence check here cannot
// over-suppress a legitimate auto-import.
//
// Residual (known limitation): this catches parameters whose annotation the
// TS scope query records as a type-binding (`p: Named`, generics, unions,
// predefined, arrays). Function-typed params (`p: () => void`), untyped
// params, destructured locals (`const { x } = …`), and catch-clause vars
// are captured by NEITHER map — the scope query emits no `@declaration` /
// `@type-binding` for them — so those shadow forms still leak an edge.
// Closing that needs shared TS scope-query/extractor changes that alter call
// resolution beyond Nuxt, so it is deferred to a follow-up rather than fixed
// here.
if (scope.filePath === filePath && scope.typeBindings.has(name)) {
return true;
}
cursor = scope.parent;
}
return false;
}
export { typescriptScopeResolver };

View file

@ -0,0 +1,3 @@
export { validate } from '../composables/clientValidate'
export { useAuto, other } from '../composables/useAuto'
export { useBarrel } from '../composables/group'

View file

@ -0,0 +1,6 @@
export function renderClient() {
validate();
useAuto();
useBarrel();
serverOnly();
}

View file

@ -0,0 +1,3 @@
export function validate() {
return 'client';
}

View file

@ -0,0 +1,3 @@
export function useBarrel() {
return 'barrel';
}

View file

@ -0,0 +1,7 @@
export function useAuto() {
return 'auto';
}
export function other() {
return 'other';
}

View file

@ -0,0 +1,5 @@
import { useAuto } from '../composables/useAuto';
export function explicitAuto() {
useAuto();
}

View file

@ -0,0 +1,6 @@
import { other } from '../composables/useAuto';
export function explicitOther() {
useAuto();
other();
}

View file

@ -0,0 +1,5 @@
import { useAuto } from '@vueuse/core';
export function renderExternal() {
useAuto();
}

View file

@ -0,0 +1,7 @@
export function useAuto() {
return 'local';
}
export function renderLocal() {
useAuto();
}

View file

@ -0,0 +1,8 @@
export class validate {}
const text = 'validate()';
/* useAuto() */
export function noise() {
new validate();
}

View file

@ -0,0 +1,3 @@
export function renderTyped(validate: ValidateFn) {
validate();
}

View file

@ -0,0 +1,7 @@
interface useAuto {
value: string;
}
export function renderTypeOnly() {
return useAuto();
}

View file

@ -0,0 +1,5 @@
export function handleServer() {
validate();
useAuto();
useBarrel();
}

View file

@ -0,0 +1,3 @@
export function serverOnly() {
return 'server-only';
}

View file

@ -0,0 +1,3 @@
export function validate() {
return 'server';
}

View file

@ -0,0 +1,164 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
describe('Nuxt/Nitro auto-import scope resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'nuxt-auto-imports'), () => {}, {
skipGraphPhases: true,
});
}, 60000);
function nuxtCalls() {
return getRelationships(result, 'CALLS').filter(
(edge) => edge.rel.reason === 'nuxt-auto-import',
);
}
it('prefers server/utils over client composables for same-named server calls', () => {
const calls = nuxtCalls();
const serverValidate = calls.find(
(edge) =>
edge.sourceFilePath.endsWith('server/api/route.ts') &&
edge.target === 'validate' &&
edge.targetFilePath.endsWith('server/utils/serverValidate.ts'),
);
const wrongClientValidate = calls.find(
(edge) =>
edge.sourceFilePath.endsWith('server/api/route.ts') &&
edge.target === 'validate' &&
edge.targetFilePath.endsWith('composables/clientValidate.ts'),
);
expect(serverValidate).toBeDefined();
expect(wrongClientValidate).toBeUndefined();
});
it('keeps server/utils out of client files while allowing client auto-imports', () => {
const calls = nuxtCalls();
expect(
calls.find(
(edge) =>
edge.sourceFilePath.endsWith('app.ts') &&
edge.target === 'validate' &&
edge.targetFilePath.endsWith('composables/clientValidate.ts'),
),
).toBeDefined();
expect(
calls.find(
(edge) =>
edge.sourceFilePath.endsWith('app.ts') &&
edge.targetFilePath.endsWith('server/utils/serverOnly.ts'),
),
).toBeUndefined();
});
it('resolves extensionless barrel directories to index files', () => {
const calls = nuxtCalls();
const imports = getRelationships(result, 'IMPORTS').filter(
(edge) => edge.rel.reason === 'nuxt-auto-import-file',
);
expect(
calls.find(
(edge) =>
edge.sourceFilePath.endsWith('app.ts') &&
edge.target === 'useBarrel' &&
edge.targetFilePath.endsWith('composables/group/index.ts'),
),
).toBeDefined();
expect(
imports.find(
(edge) =>
edge.sourceFilePath.endsWith('app.ts') &&
edge.targetFilePath.endsWith('composables/group/index.ts'),
),
).toBeDefined();
});
it('does not resolve client composables from Nitro server callers (no client fallback)', () => {
const calls = nuxtCalls();
// server/api/route.ts calls validate() (a real server/util), useAuto() and
// useBarrel() (client-only composables). Only the server/util resolves;
// Nitro does not auto-import composables/ server-side, so no edge is emitted
// to either composable.
const composableEdges = calls.filter(
(edge) =>
edge.sourceFilePath.endsWith('server/api/route.ts') &&
edge.targetFilePath.includes('/composables/'),
);
expect(composableEdges).toHaveLength(0);
// The legitimate server/util edge still resolves.
expect(
calls.find(
(edge) =>
edge.sourceFilePath.endsWith('server/api/route.ts') &&
edge.target === 'validate' &&
edge.targetFilePath.endsWith('server/utils/serverValidate.ts'),
),
).toBeDefined();
});
it('does not emit auto-import edges for local shadowing or lexical noise', () => {
const calls = nuxtCalls();
// Guard against a vacuous pass: the feature must have emitted edges elsewhere.
expect(calls.length).toBeGreaterThan(0);
expect(calls.filter((edge) => edge.sourceFilePath.endsWith('pages/local.ts'))).toHaveLength(0);
expect(calls.filter((edge) => edge.sourceFilePath.endsWith('pages/noise.ts'))).toHaveLength(0);
});
it('does not emit an auto-import edge when a typed parameter shadows the name', () => {
const calls = nuxtCalls();
expect(calls.length).toBeGreaterThan(0);
// pages/param-typed.ts has `function renderTyped(validate: ValidateFn)` and
// calls validate() — the type-annotated parameter (in scope.typeBindings)
// shadows the composable, so no nuxt edge is emitted.
expect(
calls.filter((edge) => edge.sourceFilePath.endsWith('pages/param-typed.ts')),
).toHaveLength(0);
});
it('allows type-only local declarations to coexist with value auto-import calls', () => {
const calls = nuxtCalls();
expect(
calls.find(
(edge) =>
edge.sourceFilePath.endsWith('pages/type-only.ts') &&
edge.target === 'useAuto' &&
edge.targetFilePath.endsWith('composables/useAuto.ts'),
),
).toBeDefined();
});
it('suppresses an auto-import shadowed by an explicit unresolved external import', () => {
const calls = nuxtCalls();
// Guard against a vacuous pass: the feature must have emitted edges elsewhere.
expect(calls.length).toBeGreaterThan(0);
// pages/external-import.ts does `import { useAuto } from '@vueuse/core'` (an
// unresolved external) then calls useAuto(). The explicit import shadows the
// Nuxt auto-import, so no nuxt edge is emitted from that file.
expect(
calls.filter((edge) => edge.sourceFilePath.endsWith('pages/external-import.ts')),
).toHaveLength(0);
});
it('suppresses only explicitly imported local names, not every symbol from the same source', () => {
const calls = nuxtCalls();
expect(
calls.find(
(edge) => edge.sourceFilePath.endsWith('pages/explicit.ts') && edge.target === 'useAuto',
),
).toBeDefined();
expect(
calls.find(
(edge) =>
edge.sourceFilePath.endsWith('pages/explicit-auto.ts') && edge.target === 'useAuto',
),
).toBeUndefined();
});
});

View file

@ -0,0 +1,198 @@
import { afterEach, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getNuxtAutoImportEntry,
isNitroServerRuntimeFile,
loadNuxtAutoImports,
} from '../../../../src/core/ingestion/languages/typescript/nuxt-auto-imports.js';
const tempRoots: string[] = [];
function makeRepo(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-nuxt-auto-imports-'));
tempRoots.push(root);
return root;
}
function writeFile(root: string, relPath: string, content: string): void {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
afterEach(() => {
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe('loadNuxtAutoImports', () => {
it('returns null for non-Nuxt repos without .nuxt/imports.d.ts', async () => {
const root = makeRepo();
writeFile(root, 'server/utils/helpers.ts', 'export function serverOnly() {}');
await expect(loadNuxtAutoImports(root)).resolves.toBeNull();
});
it('keeps client and server entries separate and selects by caller scope', async () => {
const root = makeRepo();
writeFile(
root,
'.nuxt/imports.d.ts',
"export { validate, useThing as useAlias } from '../composables/clientValidate'\n",
);
writeFile(root, 'composables/clientValidate.ts', 'export function validate() {}');
writeFile(root, 'server/utils/serverValidate.ts', 'export function validate() {}');
const config = await loadNuxtAutoImports(root);
expect(config).not.toBeNull();
expect(config!.clientByLocalName.get('validate')).toMatchObject({
sourceFile: 'composables/clientValidate.ts',
scope: 'client',
});
expect(config!.clientByLocalName.get('useAlias')).toMatchObject({
exportName: 'useThing',
sourceFile: 'composables/clientValidate.ts',
scope: 'client',
});
expect(config!.serverByLocalName.get('validate')).toMatchObject({
sourceFile: 'server/utils/serverValidate.ts',
scope: 'server',
});
expect(getNuxtAutoImportEntry(config!, 'validate', 'server/api/users.ts')).toMatchObject({
sourceFile: 'server/utils/serverValidate.ts',
});
expect(getNuxtAutoImportEntry(config!, 'validate', 'app.vue')).toMatchObject({
sourceFile: 'composables/clientValidate.ts',
});
// A Nitro server caller does NOT fall back to a client-only composable.
expect(getNuxtAutoImportEntry(config!, 'useAlias', 'server/api/users.ts')).toBeUndefined();
});
it('resolves extensionless directory imports to index files', async () => {
const root = makeRepo();
writeFile(root, '.nuxt/imports.d.ts', "export { useGroup } from '../composables/group'\n");
writeFile(root, 'composables/group/index.ts', 'export function useGroup() {}');
const config = await loadNuxtAutoImports(root);
expect(config!.clientByLocalName.get('useGroup')).toMatchObject({
sourceFile: 'composables/group/index.ts',
});
});
it('indexes common server/utils export forms and skips generated dirs', async () => {
const root = makeRepo();
writeFile(root, '.nuxt/imports.d.ts', '');
writeFile(
root,
'server/utils/helpers.ts',
[
'export function fn() {}',
'export async function asyncFn() {}',
'export class ServerThing {}',
'export default function defaultTool() {}',
'export const alpha = () => {}, beta = () => {};',
'export let gamma = () => {};',
'export var delta = () => {};',
].join('\n'),
);
writeFile(root, 'server/utils/jsHelper.js', 'export function jsServerTool() {}');
writeFile(root, 'server/utils/dist/generated.ts', 'export function generatedUtility() {}');
const config = await loadNuxtAutoImports(root);
expect([...config!.serverByLocalName.keys()].sort()).toEqual([
'ServerThing',
'alpha',
'asyncFn',
'beta',
'defaultTool',
'delta',
'fn',
'gamma',
'jsServerTool',
]);
expect(config!.serverByLocalName.get('jsServerTool')).toMatchObject({
sourceFile: 'server/utils/jsHelper.js',
});
});
it('captures only LHS binding names from server/utils const exports', async () => {
const root = makeRepo();
writeFile(root, '.nuxt/imports.d.ts', '');
writeFile(
root,
'server/utils/forms.ts',
[
'export const createUser = async (event: H3Event) => {};',
'export const config = { onError: () => {} };',
'export const eq = a === b;',
'export const helper: Record<string, unknown> = {};',
'export const first = 1, second = 2;',
].join('\n'),
);
const config = await loadNuxtAutoImports(root);
// Only declared binding names — never RHS arrow params (`event`), object
// keys (`onError`), operands (`a`/`b`), nor a generic-typed name dropped at
// the comma inside `Record<string, unknown>`.
expect([...config!.serverByLocalName.keys()].sort()).toEqual([
'config',
'createUser',
'eq',
'first',
'helper',
'second',
]);
});
it('ignores imports.d.ts sources that resolve outside the repo root', async () => {
const root = makeRepo();
// A real file in its own temp dir OUTSIDE the repo, reachable from .nuxt only
// by directory traversal. Its own mkdtemp dir is tracked for cleanup.
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-nuxt-escape-'));
tempRoots.push(outsideDir);
const outside = path.join(outsideDir, 'escape.ts');
fs.writeFileSync(outside, 'export function escape() {}', 'utf8');
// Relative traversal from root/.nuxt to the out-of-repo file (extensionless).
const traversal = path
.relative(path.join(root, '.nuxt'), outside)
.replace(/\\/g, '/')
.replace(/\.ts$/, '');
writeFile(
root,
'.nuxt/imports.d.ts',
[
`export { escape } from '${traversal}'`,
"export { useGood } from '../composables/good'",
].join('\n'),
);
writeFile(root, 'composables/good.ts', 'export function useGood() {}');
const config = await loadNuxtAutoImports(root);
// The traversal source is skipped (no entry, no throw); the in-repo one resolves.
expect(config!.clientByLocalName.has('escape')).toBe(false);
expect(config!.clientByLocalName.get('useGood')).toMatchObject({
sourceFile: 'composables/good.ts',
});
});
});
describe('isNitroServerRuntimeFile', () => {
it('matches only Nitro runtime entry directories', () => {
expect(isNitroServerRuntimeFile('server/api/users.ts')).toBe(true);
expect(isNitroServerRuntimeFile('server/routes/feed.ts')).toBe(true);
expect(isNitroServerRuntimeFile('server/middleware/auth.ts')).toBe(true);
expect(isNitroServerRuntimeFile('server/plugins/init.ts')).toBe(true);
expect(isNitroServerRuntimeFile('server/tasks/cleanup.ts')).toBe(true);
expect(isNitroServerRuntimeFile('server/utils/auth.ts')).toBe(false);
expect(isNitroServerRuntimeFile('pages/index.ts')).toBe(false);
});
});