mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
feat: ✨ resolve Nuxt/Nitro auto-imports in TypeScript scope resolver
This commit is contained in:
parent
5cb00119e8
commit
81ab7b0f51
2 changed files with 358 additions and 5 deletions
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* Nuxt v4 auto-import resolution for the TypeScript scope-resolver.
|
||||
*
|
||||
* Nuxt and Nitro both make symbols available project-wide without explicit
|
||||
* import statements. Two sources cover the common cases:
|
||||
*
|
||||
* 1. `.nuxt/imports.d.ts` - client/shared composables generated by Nuxt at
|
||||
* build time. Only entries whose source is a project-local relative path
|
||||
* (i.e. NOT a package in node_modules or a Nuxt runtime alias) are
|
||||
* indexed. These are project utils and composables the developer wrote.
|
||||
*
|
||||
* 2. `server/utils/**` - Nitro auto-imports all exports from this directory
|
||||
* tree into every `server/api/`, `server/routes/`, and
|
||||
* `server/middleware/` file. These are not included in `imports.d.ts`
|
||||
* because they are server-only.
|
||||
*
|
||||
* Both sources produce the same `NuxtAutoImportEntry` shape so the edge-
|
||||
* emission pass can treat them uniformly.
|
||||
*
|
||||
* Detection: if `.nuxt/imports.d.ts` is absent the repo is not a Nuxt project
|
||||
* and this module returns null immediately, adding zero overhead to non-Nuxt
|
||||
* analysis runs. The `server/utils` scan is only attempted when the Nuxt
|
||||
* detection succeeds, so it also costs nothing for non-Nuxt repos.
|
||||
*
|
||||
* Limitations:
|
||||
* - Calls inside string literals or single-line comments are not excluded
|
||||
* by the content scanner. False-positive edges are tagged with
|
||||
* confidence 0.75 to signal that they are heuristic, not type-checked.
|
||||
* - Re-exports with non-matching aliases (e.g. `flatUnwrap as unwrapSlot`
|
||||
* where `flatUnwrap` is the graph node name) try both the original export
|
||||
* name and the local alias when looking up the graph node.
|
||||
* - Only direct exports are indexed; barrel re-exports that chain through
|
||||
* multiple files are not followed.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// ---- types ------------------------------------------------------------------
|
||||
|
||||
/** A single auto-imported symbol and where it lives in the repo. */
|
||||
export interface NuxtAutoImportEntry {
|
||||
/** The name used at call sites (the alias when `export { X as Y }` form). */
|
||||
readonly localName: string;
|
||||
/** The original export name in the source file (before `as`). */
|
||||
readonly exportName: string;
|
||||
/** Repo-relative POSIX path to the source file (with extension). */
|
||||
readonly sourceFile: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated auto-import map for one Nuxt workspace, keyed by the local
|
||||
* name used in calling code.
|
||||
*/
|
||||
export interface NuxtAutoImportConfig {
|
||||
readonly byLocalName: ReadonlyMap<string, NuxtAutoImportEntry>;
|
||||
}
|
||||
|
||||
// ---- loader -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load the Nuxt auto-import map for `repoRoot`.
|
||||
*
|
||||
* Returns null when:
|
||||
* - `.nuxt/imports.d.ts` does not exist (non-Nuxt project), or
|
||||
* - the file exists but yields zero project-local entries.
|
||||
*/
|
||||
export async function loadNuxtAutoImports(repoRoot: string): Promise<NuxtAutoImportConfig | null> {
|
||||
const byLocalName = new Map<string, NuxtAutoImportEntry>();
|
||||
|
||||
await collectImportsDts(repoRoot, byLocalName);
|
||||
|
||||
// Only attempt the server/utils scan when we confirmed this is a Nuxt project
|
||||
// (imports.d.ts was present and populated the map, or at least the file existed).
|
||||
const nuxtDirExists = await dirExists(path.join(repoRoot, '.nuxt'));
|
||||
if (nuxtDirExists) {
|
||||
await collectNitroServerUtils(repoRoot, byLocalName);
|
||||
}
|
||||
|
||||
return byLocalName.size > 0 ? { byLocalName } : null;
|
||||
}
|
||||
|
||||
// ---- .nuxt/imports.d.ts -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse `.nuxt/imports.d.ts` and add project-local entries to `byLocalName`.
|
||||
*
|
||||
* The file contains lines of the form:
|
||||
* export { name1, name2, origName as alias } from 'source'
|
||||
*
|
||||
* Only entries with a source that is a project-local relative path
|
||||
* (starts with `./` or `../` AND does not contain `node_modules`) are
|
||||
* included. Nuxt runtime paths (`#app/...`) and third-party packages are
|
||||
* intentionally skipped because they have no graph nodes in the repo.
|
||||
*/
|
||||
async function collectImportsDts(
|
||||
repoRoot: string,
|
||||
byLocalName: Map<string, NuxtAutoImportEntry>,
|
||||
): Promise<void> {
|
||||
const importsPath = path.join(repoRoot, '.nuxt', 'imports.d.ts');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(importsPath, 'utf-8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const nuxtDir = path.join(repoRoot, '.nuxt');
|
||||
// Matches: export { name1, name2 as alias } from 'source'
|
||||
const lineRe = /^export\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/gm;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = lineRe.exec(content)) !== null) {
|
||||
const symbolsRaw = m[1]!;
|
||||
const source = m[2]!;
|
||||
|
||||
if (!isProjectLocalPath(source)) continue;
|
||||
|
||||
const resolvedFile = await resolveExtension(path.resolve(nuxtDir, source));
|
||||
if (resolvedFile === null) continue;
|
||||
|
||||
const sourceFile = toRepoPosix(repoRoot, resolvedFile);
|
||||
|
||||
for (const raw of symbolsRaw.split(',')) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const asIdx = trimmed.indexOf(' as ');
|
||||
const exportName = asIdx >= 0 ? trimmed.slice(0, asIdx).trim() : trimmed;
|
||||
const localName = asIdx >= 0 ? trimmed.slice(asIdx + 4).trim() : trimmed;
|
||||
|
||||
if (!localName || !exportName) continue;
|
||||
|
||||
if (!byLocalName.has(localName)) {
|
||||
byLocalName.set(localName, { localName, exportName, sourceFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- server/utils (Nitro auto-imports) --------------------------------------
|
||||
|
||||
/**
|
||||
* Scan `server/utils/**` and register every exported symbol as a Nitro
|
||||
* auto-import. Nitro makes all exports from this directory tree available
|
||||
* without an import statement in server routes and middleware.
|
||||
*/
|
||||
async function collectNitroServerUtils(
|
||||
repoRoot: string,
|
||||
byLocalName: Map<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);
|
||||
|
||||
// Match top-level exported functions and constants.
|
||||
// Handles: export function X, export async function X, export const X =
|
||||
// Does NOT attempt to match re-exports from other modules.
|
||||
const exportRe =
|
||||
/^export\s+(?:async\s+)?function\s+(\w+)|^export\s+const\s+(\w+)\s*[=:]/gm;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = exportRe.exec(content)) !== null) {
|
||||
const name = (m[1] ?? m[2])!;
|
||||
if (!byLocalName.has(name)) {
|
||||
byLocalName.set(name, { localName: name, exportName: name, sourceFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
|
||||
function isProjectLocalPath(source: string): boolean {
|
||||
if (!source.startsWith('./') && !source.startsWith('../')) return false;
|
||||
if (source.includes('node_modules')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resolveExtension(base: string): Promise<string | null> {
|
||||
for (const ext of ['.ts', '.tsx', '.js', '.jsx', '']) {
|
||||
const candidate = ext ? base + ext : base;
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toRepoPosix(repoRoot: string, absPath: string): string {
|
||||
return path.relative(repoRoot, absPath).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
async function dirExists(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(dirPath);
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect `.ts` and `.tsx` files under a directory. */
|
||||
async function collectTsFiles(dir: string): Promise<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) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...(await collectTsFiles(full)));
|
||||
} else if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
|
@ -14,9 +14,11 @@
|
|||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { typescriptProvider } from '../typescript.js';
|
||||
import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js';
|
||||
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
|
||||
|
|
@ -26,10 +28,13 @@ import {
|
|||
resolveTsTarget,
|
||||
type TsResolveContext,
|
||||
} from './index.js';
|
||||
import { loadNuxtAutoImports, type NuxtAutoImportConfig } from './nuxt-auto-imports.js';
|
||||
|
||||
/** Shape the orchestrator threads in via `RunScopeResolutionInput.resolutionConfig`. */
|
||||
interface TypescriptResolutionConfig {
|
||||
readonly tsconfigPaths: TsconfigPaths | null;
|
||||
/** Nuxt/Nitro auto-import map. Null for non-Nuxt projects. */
|
||||
readonly nuxtAutoImports: NuxtAutoImportConfig | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,11 +97,14 @@ const typescriptScopeResolver: ScopeResolver = {
|
|||
resolveImportTarget: makeTsResolveImportTarget(),
|
||||
|
||||
// Threaded into `resolveImportTarget` so tsconfig path aliases
|
||||
// (`@/services/user`, `~/x`, …) resolve through the same standard
|
||||
// (`@/services/user`, `~/x`, ...) resolve through the same standard
|
||||
// resolver branch the legacy DAG uses. One I/O round-trip per
|
||||
// workspace pass; the orchestrator awaits this once.
|
||||
// `nuxtAutoImports` is null for non-Nuxt projects (no .nuxt/imports.d.ts),
|
||||
// so this adds zero overhead to ordinary TypeScript repos.
|
||||
loadResolutionConfig: async (repoPath: string) => ({
|
||||
tsconfigPaths: await loadTsconfigPaths(repoPath),
|
||||
nuxtAutoImports: await loadNuxtAutoImports(repoPath),
|
||||
}),
|
||||
|
||||
// TypeScript declaration merging + LEGB: local > import > wildcard,
|
||||
|
|
@ -130,24 +138,134 @@ const typescriptScopeResolver: ScopeResolver = {
|
|||
propagatesReturnTypesAcrossImports: true,
|
||||
|
||||
// TypeScript uses `.values()` / `.keys()` method-call syntax for
|
||||
// collection views — no property-style accessors like C#'s
|
||||
// collection views -- no property-style accessors like C#'s
|
||||
// `Dictionary<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. Scans the raw source for identifier call-patterns (`name(`) and
|
||||
* checks each against the auto-import map.
|
||||
* 3. For each hit that is not already explicitly imported, emits a CALLS
|
||||
* edge from the file's File node to the target function node, and an
|
||||
* IMPORTS edge from the caller file to the source file (once per pair).
|
||||
*
|
||||
* Confidence is 0.75 (below the 0.9 used for fully resolved edges) to
|
||||
* signal that these edges are heuristic: the content scanner does not
|
||||
* filter string literals or comments.
|
||||
*/
|
||||
emitPostResolutionEdges(graph, parsedFiles, nodeLookup, indexes, ctx) {
|
||||
const cfg = ctx.resolutionConfig as TypescriptResolutionConfig | undefined;
|
||||
const autoImports = cfg?.nuxtAutoImports;
|
||||
if (!autoImports || autoImports.byLocalName.size === 0) return;
|
||||
|
||||
// Pre-index: localName -> entry for fast lookup during content scan.
|
||||
const { byLocalName } = autoImports;
|
||||
|
||||
// Regex matches bare identifier call sites: word-boundary + name + "(".
|
||||
// Excludes `new X(` (constructor calls are not free-function auto-imports).
|
||||
const CALL_RE = /(?<![.\w])([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
|
||||
|
||||
for (const parsedFile of parsedFiles) {
|
||||
const { filePath } = parsedFile;
|
||||
if (!filePath.endsWith('.ts') && !filePath.endsWith('.tsx')) continue;
|
||||
|
||||
const content = ctx.fileContents.get(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
// Collect files already brought in by an explicit import in this file.
|
||||
const explicitImports = new Set<string>();
|
||||
for (const [scopeId, edges] of indexes.imports) {
|
||||
const scope = indexes.scopeTree.getScope(scopeId);
|
||||
if (scope?.filePath !== filePath) continue;
|
||||
for (const edge of edges) {
|
||||
if (edge.targetFile !== null) explicitImports.add(edge.targetFile);
|
||||
}
|
||||
}
|
||||
|
||||
const fileId = generateId('File', filePath);
|
||||
// Track (sourceFile) pairs already handled for this caller to avoid
|
||||
// emitting duplicate IMPORTS edges and duplicate CALLS edges per symbol.
|
||||
const emittedImports = new Set<string>();
|
||||
const emittedCalls = new Set<string>();
|
||||
|
||||
CALL_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = CALL_RE.exec(content)) !== null) {
|
||||
const localName = m[1]!;
|
||||
const entry = byLocalName.get(localName);
|
||||
if (!entry) continue;
|
||||
|
||||
const { exportName, sourceFile } = entry;
|
||||
|
||||
// Skip when the file already has an explicit import from this source.
|
||||
if (explicitImports.has(sourceFile)) continue;
|
||||
|
||||
// Emit one IMPORTS edge per (caller, sourceFile) pair.
|
||||
if (!emittedImports.has(sourceFile)) {
|
||||
emittedImports.add(sourceFile);
|
||||
const targetFileId = generateId('File', sourceFile);
|
||||
if (graph.getNode(targetFileId)) {
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPORTS', `${fileId}->nuxt-auto-import->${targetFileId}`),
|
||||
sourceId: fileId,
|
||||
targetId: targetFileId,
|
||||
type: 'IMPORTS',
|
||||
confidence: 0.75,
|
||||
reason: 'nuxt-auto-import-file',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit one CALLS edge per (caller, symbol) pair.
|
||||
const callKey = `${sourceFile}::${localName}`;
|
||||
if (emittedCalls.has(callKey)) continue;
|
||||
emittedCalls.add(callKey);
|
||||
|
||||
// Look up the graph node by export name first, fall back to local name.
|
||||
// The fallback handles `default as X` where the function is named X.
|
||||
const targetNodeId =
|
||||
nodeLookup.get(simpleKey(sourceFile, exportName)) ??
|
||||
(exportName !== localName ? nodeLookup.get(simpleKey(sourceFile, localName)) : undefined);
|
||||
|
||||
if (!targetNodeId || !graph.getNode(targetNodeId)) continue;
|
||||
|
||||
graph.addRelationship({
|
||||
id: generateId('CALLS', `${fileId}:nuxt-auto-import:${localName}->${targetNodeId}`),
|
||||
sourceId: fileId,
|
||||
targetId: targetNodeId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.75,
|
||||
reason: 'nuxt-auto-import',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export { typescriptScopeResolver };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue