mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
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:
parent
2984b5826e
commit
385cb78c5f
19 changed files with 1017 additions and 6 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/.nuxt/imports.d.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/.nuxt/imports.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { validate } from '../composables/clientValidate'
|
||||
export { useAuto, other } from '../composables/useAuto'
|
||||
export { useBarrel } from '../composables/group'
|
||||
6
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/app.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/app.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function renderClient() {
|
||||
validate();
|
||||
useAuto();
|
||||
useBarrel();
|
||||
serverOnly();
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/clientValidate.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/clientValidate.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function validate() {
|
||||
return 'client';
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/group/index.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/group/index.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function useBarrel() {
|
||||
return 'barrel';
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/useAuto.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/composables/useAuto.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function useAuto() {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
export function other() {
|
||||
return 'other';
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit-auto.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit-auto.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { useAuto } from '../composables/useAuto';
|
||||
|
||||
export function explicitAuto() {
|
||||
useAuto();
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit.ts
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/explicit.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { other } from '../composables/useAuto';
|
||||
|
||||
export function explicitOther() {
|
||||
useAuto();
|
||||
other();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/external-import.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/external-import.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { useAuto } from '@vueuse/core';
|
||||
|
||||
export function renderExternal() {
|
||||
useAuto();
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/local.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/local.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function useAuto() {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
export function renderLocal() {
|
||||
useAuto();
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/noise.ts
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/noise.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export class validate {}
|
||||
|
||||
const text = 'validate()';
|
||||
/* useAuto() */
|
||||
|
||||
export function noise() {
|
||||
new validate();
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/param-typed.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/param-typed.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function renderTyped(validate: ValidateFn) {
|
||||
validate();
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/type-only.ts
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/pages/type-only.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
interface useAuto {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function renderTypeOnly() {
|
||||
return useAuto();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/api/route.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/api/route.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function handleServer() {
|
||||
validate();
|
||||
useAuto();
|
||||
useBarrel();
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverOnly.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverOnly.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function serverOnly() {
|
||||
return 'server-only';
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverValidate.ts
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/nuxt-auto-imports/server/utils/serverValidate.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function validate() {
|
||||
return 'server';
|
||||
}
|
||||
164
gitnexus/test/integration/resolvers/nuxt-scope.test.ts
Normal file
164
gitnexus/test/integration/resolvers/nuxt-scope.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue