GitNexus/gitnexus/test/integration/resolvers/nuxt-scope.test.ts
Dorian Portillo 9aa65ae3f8
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
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>
2026-06-24 12:32:51 +01:00

164 lines
5.8 KiB
TypeScript

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();
});
});