From 385cb78c5fccaee9dc429cea41f02d883cab191e Mon Sep 17 00:00:00 2001 From: Dorian Portillo Date: Wed, 24 Jun 2026 04:32:51 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20resolve=20Nuxt/Nitro=20auto?= =?UTF-8?q?-imports=20in=20TypeScript=20scope=20resolver=20(#2026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: :sparkles: resolve Nuxt/Nitro auto-imports in TypeScript scope resolver * fix: :bug: skip self-referential edges in Nuxt auto-import emission * fix: :bug: 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 = …`) 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) 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) 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) 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) 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) 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) 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) Claude-Session: https://claude.ai/code/session_01T4W25WLfYD1JNy8icxeLPU --------- Co-authored-by: Gergő Magyar Co-authored-by: Claude Opus 4.8 (1M context) --- .../languages/typescript/nuxt-auto-imports.ts | 377 ++++++++++++++++++ .../languages/typescript/scope-resolver.ts | 210 +++++++++- .../nuxt-auto-imports/.nuxt/imports.d.ts | 3 + .../lang-resolution/nuxt-auto-imports/app.ts | 6 + .../composables/clientValidate.ts | 3 + .../composables/group/index.ts | 3 + .../nuxt-auto-imports/composables/useAuto.ts | 7 + .../nuxt-auto-imports/pages/explicit-auto.ts | 5 + .../nuxt-auto-imports/pages/explicit.ts | 6 + .../pages/external-import.ts | 5 + .../nuxt-auto-imports/pages/local.ts | 7 + .../nuxt-auto-imports/pages/noise.ts | 8 + .../nuxt-auto-imports/pages/param-typed.ts | 3 + .../nuxt-auto-imports/pages/type-only.ts | 7 + .../nuxt-auto-imports/server/api/route.ts | 5 + .../server/utils/serverOnly.ts | 3 + .../server/utils/serverValidate.ts | 3 + .../integration/resolvers/nuxt-scope.test.ts | 164 ++++++++ .../typescript/nuxt-auto-imports.test.ts | 198 +++++++++ 19 files changed, 1017 insertions(+), 6 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/.nuxt/imports.d.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/clientValidate.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/group/index.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/useAuto.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit-auto.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/external-import.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/local.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/noise.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/param-typed.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/type-only.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/api/route.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverOnly.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverValidate.ts create mode 100644 gitnexus/test/integration/resolvers/nuxt-scope.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.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 0000000000..ec7800dd12 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts @@ -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; + readonly serverByLocalName: ReadonlyMap; +} + +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 { + const clientByLocalName = new Map(); + const serverByLocalName = new Map(); + + 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, +): Promise { + 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, +): 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); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + + 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 = …`). + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index d4b2237dc5..91c1475356 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -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([ + '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.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>(); + 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(); + 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(); + // 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(); + + 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(); + 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 }; diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/.nuxt/imports.d.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/.nuxt/imports.d.ts new file mode 100644 index 0000000000..7b12b83ab2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/.nuxt/imports.d.ts @@ -0,0 +1,3 @@ +export { validate } from '../composables/clientValidate' +export { useAuto, other } from '../composables/useAuto' +export { useBarrel } from '../composables/group' diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/app.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/app.ts new file mode 100644 index 0000000000..b7082c5825 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/app.ts @@ -0,0 +1,6 @@ +export function renderClient() { + validate(); + useAuto(); + useBarrel(); + serverOnly(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/clientValidate.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/clientValidate.ts new file mode 100644 index 0000000000..655abb1024 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/clientValidate.ts @@ -0,0 +1,3 @@ +export function validate() { + return 'client'; +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/group/index.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/group/index.ts new file mode 100644 index 0000000000..6393a0087f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/group/index.ts @@ -0,0 +1,3 @@ +export function useBarrel() { + return 'barrel'; +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/useAuto.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/useAuto.ts new file mode 100644 index 0000000000..cd00f6be0d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/useAuto.ts @@ -0,0 +1,7 @@ +export function useAuto() { + return 'auto'; +} + +export function other() { + return 'other'; +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit-auto.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit-auto.ts new file mode 100644 index 0000000000..9ec8c2defd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit-auto.ts @@ -0,0 +1,5 @@ +import { useAuto } from '../composables/useAuto'; + +export function explicitAuto() { + useAuto(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit.ts new file mode 100644 index 0000000000..5362a7f965 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit.ts @@ -0,0 +1,6 @@ +import { other } from '../composables/useAuto'; + +export function explicitOther() { + useAuto(); + other(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/external-import.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/external-import.ts new file mode 100644 index 0000000000..ff21af2995 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/external-import.ts @@ -0,0 +1,5 @@ +import { useAuto } from '@vueuse/core'; + +export function renderExternal() { + useAuto(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/local.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/local.ts new file mode 100644 index 0000000000..dde6e5da6c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/local.ts @@ -0,0 +1,7 @@ +export function useAuto() { + return 'local'; +} + +export function renderLocal() { + useAuto(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/noise.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/noise.ts new file mode 100644 index 0000000000..9a4c9f050f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/noise.ts @@ -0,0 +1,8 @@ +export class validate {} + +const text = 'validate()'; +/* useAuto() */ + +export function noise() { + new validate(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/param-typed.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/param-typed.ts new file mode 100644 index 0000000000..f84d8fd10f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/param-typed.ts @@ -0,0 +1,3 @@ +export function renderTyped(validate: ValidateFn) { + validate(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/type-only.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/type-only.ts new file mode 100644 index 0000000000..4d3f400280 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/type-only.ts @@ -0,0 +1,7 @@ +interface useAuto { + value: string; +} + +export function renderTypeOnly() { + return useAuto(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/api/route.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/api/route.ts new file mode 100644 index 0000000000..6b8f4fd32b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/api/route.ts @@ -0,0 +1,5 @@ +export function handleServer() { + validate(); + useAuto(); + useBarrel(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverOnly.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverOnly.ts new file mode 100644 index 0000000000..9e7a056403 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverOnly.ts @@ -0,0 +1,3 @@ +export function serverOnly() { + return 'server-only'; +} diff --git a/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverValidate.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverValidate.ts new file mode 100644 index 0000000000..7858f7d75c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverValidate.ts @@ -0,0 +1,3 @@ +export function validate() { + return 'server'; +} diff --git a/gitnexus/test/integration/resolvers/nuxt-scope.test.ts b/gitnexus/test/integration/resolvers/nuxt-scope.test.ts new file mode 100644 index 0000000000..518a638f8d --- /dev/null +++ b/gitnexus/test/integration/resolvers/nuxt-scope.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts b/gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts new file mode 100644 index 0000000000..af04ebb5d3 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts @@ -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 = {};', + '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`. + 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); + }); +});