diff --git a/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts b/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts index 5dd31c9e7..adb9a6ce0 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/nuxt-auto-imports.ts @@ -14,8 +14,9 @@ * `server/middleware/` file. These are not included in `imports.d.ts` * because they are server-only. * - * Both sources produce the same `NuxtAutoImportEntry` shape so the edge- - * emission pass can treat them uniformly. + * 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 @@ -23,9 +24,8 @@ * detection succeeds, so it also costs nothing for non-Nuxt repos. * * Limitations: - * - Calls inside string literals or single-line comments are not excluded - * by the content scanner. False-positive edges are tagged with - * confidence 0.75 to signal that they are heuristic, not type-checked. + * - 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. @@ -38,6 +38,8 @@ 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). */ @@ -46,16 +48,37 @@ export interface NuxtAutoImportEntry { 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 map for one Nuxt workspace, keyed by the local - * name used in calling code. + * 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 byLocalName: ReadonlyMap; + 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; +const VARIABLE_DECLARATOR_RE = /\b([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=,]+)?=/g; + // ---- loader ----------------------------------------------------------------- /** @@ -72,18 +95,44 @@ export interface NuxtAutoImportConfig { * are not Nuxt projects. */ export async function loadNuxtAutoImports(repoRoot: string): Promise { - const byLocalName = new Map(); + const clientByLocalName = new Map(); + const serverByLocalName = new Map(); - const nuxtInitialized = await collectImportsDts(repoRoot, byLocalName); + 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, byLocalName); + await collectNitroServerUtils(repoRoot, serverByLocalName); } - return byLocalName.size > 0 ? { byLocalName } : null; + 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)) { + return config.serverByLocalName.get(localName) ?? config.clientByLocalName.get(localName); + } + return config.clientByLocalName.get(localName); +} + +export function isNitroServerRuntimeFile(filePath: string): boolean { + const normalized = filePath.replace(/\\/g, '/'); + return ( + normalized.startsWith('server/api/') || + normalized.startsWith('server/routes/') || + normalized.startsWith('server/middleware/') + ); } // ---- .nuxt/imports.d.ts ----------------------------------------------------- @@ -113,11 +162,10 @@ async function collectImportsDts( } const nuxtDir = path.join(repoRoot, '.nuxt'); - // Matches: export { name1, name2 as alias } from 'source' - const lineRe = /^export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/gm; + IMPORTS_DTS_EXPORT_RE.lastIndex = 0; let m: RegExpExecArray | null; - while ((m = lineRe.exec(content)) !== null) { + while ((m = IMPORTS_DTS_EXPORT_RE.exec(content)) !== null) { const symbolsRaw = m[1]!; const source = m[2]!; @@ -139,7 +187,7 @@ async function collectImportsDts( if (!localName || !exportName) continue; if (!byLocalName.has(localName)) { - byLocalName.set(localName, { localName, exportName, sourceFile }); + byLocalName.set(localName, { localName, exportName, sourceFile, scope: 'client' }); } } } @@ -172,17 +220,9 @@ async function collectNitroServerUtils( const sourceFile = toRepoPosix(repoRoot, absPath); - // Match top-level exported functions and constants. - // Handles: export function X, export async function X, export const X = - // Does NOT attempt to match re-exports from other modules. - const exportRe = - /^export\s+(?:async\s+)?function\s+(\w+)|^export\s+const\s+(\w+)\s*[=:]/gm; - let m: RegExpExecArray | null; - - while ((m = exportRe.exec(content)) !== null) { - const name = (m[1] ?? m[2])!; + for (const name of extractNitroExportNames(content)) { if (!byLocalName.has(name)) { - byLocalName.set(name, { localName: name, exportName: name, sourceFile }); + byLocalName.set(name, { localName: name, exportName: name, sourceFile, scope: 'server' }); } } } @@ -197,16 +237,25 @@ function isProjectLocalPath(source: string): boolean { } async function resolveExtension(base: string): Promise { - for (const ext of ['.ts', '.tsx', '.js', '.jsx', '']) { - const candidate = ext ? base + ext : base; - try { - await fs.access(candidate); - return candidate; - } catch { - // try next - } + 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; } - return null; } function toRepoPosix(repoRoot: string, absPath: string): string { @@ -232,6 +281,7 @@ async function collectTsFiles(dir: string): Promise { 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))); @@ -241,3 +291,27 @@ async function collectTsFiles(dir: string): Promise { } return results; } + +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) { + const declarationText = variableDeclaration[1]!; + VARIABLE_DECLARATOR_RE.lastIndex = 0; + let declarator: RegExpExecArray | null; + while ((declarator = VARIABLE_DECLARATOR_RE.exec(declarationText)) !== null) { + names.add(declarator[1]!); + } + } + + return [...names]; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index 0c94bf455..2e40e85fb 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -12,13 +12,14 @@ * ./query.ts (TYPESCRIPT_SCOPE_QUERY constant). */ -import type { ParsedFile } from 'gitnexus-shared'; +import type { 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'; @@ -28,7 +29,12 @@ import { resolveTsTarget, type TsResolveContext, } from './index.js'; -import { loadNuxtAutoImports, type NuxtAutoImportConfig } from './nuxt-auto-imports.js'; +import { + getNuxtAutoImportEntry, + hasNuxtAutoImports, + loadNuxtAutoImports, + type NuxtAutoImportConfig, +} from './nuxt-auto-imports.js'; /** Shape the orchestrator threads in via `RunScopeResolutionInput.resolutionConfig`. */ interface TypescriptResolutionConfig { @@ -169,71 +175,66 @@ const typescriptScopeResolver: ScopeResolver = { * For each TypeScript file the hook: * 1. Builds the set of files already explicitly imported (to avoid * creating duplicate edges for symbols imported conventionally). - * 2. Scans the raw source for identifier call-patterns (`name(`) and - * checks each against the auto-import map. - * 3. For each hit that is not already explicitly imported, emits a CALLS - * edge from the file's File node to the target function node, and an - * IMPORTS edge from the caller file to the source file (once per pair). + * 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: the content scanner does not - * filter string literals or comments. + * 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 || autoImports.byLocalName.size === 0) return; + if (!autoImports || !hasNuxtAutoImports(autoImports)) return; - // Pre-index: localName -> entry for fast lookup during content scan. - const { byLocalName } = autoImports; - - // Pre-build a file -> explicit-import-targets index so the per-file - // lookup below is O(1) rather than scanning all import edges every iteration. - const explicitImportsByFile = new Map>(); + // 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 targets = explicitImportsByFile.get(scope.filePath); - if (!targets) { - targets = new Set(); - explicitImportsByFile.set(scope.filePath, targets); + let names = explicitImportNamesByFile.get(scope.filePath); + if (!names) { + names = new Set(); + explicitImportNamesByFile.set(scope.filePath, names); } for (const edge of edges) { - if (edge.targetFile !== null) targets.add(edge.targetFile); + if (edge.targetFile !== null) names.add(edge.localName); } } - // Regex matches bare identifier call sites: word-boundary + name + "(". - // Excludes `new X(` (constructor calls are not free-function auto-imports). - const CALL_RE = /(?(); - 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(); - CALL_RE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = CALL_RE.exec(content)) !== null) { - const localName = m[1]!; - const entry = byLocalName.get(localName); + 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 has an explicit import from this source, - // or when the file IS the source (a file cannot auto-import itself). - if (explicitImports.has(sourceFile) || sourceFile === filePath) continue; + // 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)) { @@ -277,4 +278,32 @@ const typescriptScopeResolver: ScopeResolver = { }, }; + +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) + ) { + 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 000000000..7b12b83ab --- /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 000000000..4285d154b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/app.ts @@ -0,0 +1,5 @@ +export function renderClient() { + validate(); + useAuto(); + 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 000000000..655abb102 --- /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 000000000..6393a0087 --- /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 000000000..cd00f6be0 --- /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 000000000..9ec8c2def --- /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 000000000..5362a7f96 --- /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/local.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/local.ts new file mode 100644 index 000000000..dde6e5da6 --- /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 000000000..9a4c9f050 --- /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/server/api/route.ts b/gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/api/route.ts new file mode 100644 index 000000000..6b8f4fd32 --- /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 000000000..9e7a05640 --- /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 000000000..7858f7d75 --- /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 000000000..b3e3f85a7 --- /dev/null +++ b/gitnexus/test/integration/resolvers/nuxt-scope.test.ts @@ -0,0 +1,106 @@ +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('server/api/route.ts') && + edge.target === 'useBarrel' && + edge.targetFilePath.endsWith('composables/group/index.ts'), + ), + ).toBeDefined(); + expect( + imports.find( + (edge) => + edge.sourceFilePath.endsWith('server/api/route.ts') && + edge.targetFilePath.endsWith('composables/group/index.ts'), + ), + ).toBeDefined(); + }); + + it('does not emit auto-import edges for local shadowing or lexical noise', () => { + const calls = nuxtCalls(); + + 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('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 000000000..4db56eb02 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/typescript/nuxt-auto-imports.test.ts @@ -0,0 +1,126 @@ +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', + }); + }); + + 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/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', + ]); + }); +}); + +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/utils/auth.ts')).toBe(false); + expect(isNitroServerRuntimeFile('pages/index.ts')).toBe(false); + }); +});