Răsfoiți Sursa

feat(impact): Nuxt nested auto-imported component resolution (<MediaCard> → media/Card.vue)

Nuxt auto-imports a nested component by a DIRECTORY-PREFIXED name —
components/media/Card.vue is used in templates as <MediaCard/>, not <Card/> — but
the component node is named by basename (`Card`), so the usage never resolved and
the nested component looked unused. Two parts in vueTemplateEdges: (1) match
PascalCase component tags (`<MediaCard>`), not only kebab-case — the extractor's
template-tag references were the only path before, and they fail on the prefixed
name; (2) a `nuxtComponentName` map (`media/Card.vue` → `MediaCard`, incl. Nuxt's
dir-name de-dup) so the prefixed usage resolves to the basename node.

nuxt/movies 47.6% -> 93.5% (all nested carousel/media components now covered;
2 residual zeros are genuine frontiers — an unused carousel variant + an
unimported constants file). false edges 0, node count unchanged, suite 1183.
Regression test fails without the fix. Vue-only (the loop is gated to .vue files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 săptămâni în urmă
părinte
comite
1dec7654f0
3 a modificat fișierele cu 88 adăugiri și 1 ștergeri
  1. 1 0
      CHANGELOG.md
  2. 37 0
      __tests__/extraction.test.ts
  3. 50 1
      src/resolution/callback-synthesizer.ts

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Rust cross-module function calls now resolve to the right file. A call to a sibling submodule's function — `users::router()`, the common router-assembly / handler-registration pattern where `mod users;` makes `users` a child of the current module — is now resolved relative to the current module, not only the crate root. Deeper module-path calls (`database::profiles::find()` — the `db.run(|c| …)` data-access shape) now resolve too; these were being discarded before resolution even ran, because the path's leaf function name was never checked. Previously such a call linked to nothing, so a module reached only as `module::path::function()` looked like it had no dependents; a web app wired this way (Axum, Rocket, and similar) now surfaces its handler and data-access modules' real callers. (Rust)
 - Rocket route handlers now connect to where they're mounted. A handler registered in a `routes![a::b::handler, …]` or `catchers![…]` macro used to be invisible — the macro body is a raw token tree, so the handler looked like it had no caller (Rocket mounts it at runtime) and its file showed no dependents. The handler paths are now read out of the macro and linked to the `mount`/`register` call, so editing a Rocket handler surfaces its route registration and a routes module is no longer reported as unused. (Rust, Rocket)
 - SvelteKit pages now connect to their server `load` functions. SvelteKit wires a `+page.server.js` / `+page.js` `load` (and form `actions`) to the sibling `+page.svelte`'s `data` by file path, with no import between them — so editing a `load` previously showed no impact on the page it feeds. Each page is now linked to the `load`/`actions` in its own route directory (and likewise for `+layout`), so editing a loader surfaces the page that renders its data, and tracing a page reaches its server-side data source. (Svelte, SvelteKit)
+- Nuxt nested components are now connected to where they're used. Nuxt auto-imports a component in a subdirectory by a directory-prefixed name — `components/media/Card.vue` is used in templates as `<MediaCard/>` — but it was tracked by its file name (`Card`), so the usage didn't resolve and the component looked unused. PascalCase component tags (`<MediaCard>`, `<NavBar>`) in a `.vue` template are now matched, falling back to the Nuxt directory-prefixed name, so editing a nested component surfaces every page and component that renders it. (Vue, Nuxt)
 - Swift property wrappers and attributes are now connected. A `@Argument` / `@Published` / `@State` / custom `@propertyWrapper` on a property — and attributes on types, methods, and functions (`@objc`, `@MainActor`, …) — now record a dependency on the wrapper/attribute type. Previously these were dropped entirely (Swift attributes parse differently from other languages, and stored properties weren't being inspected), so the wrapper type looked unused and the file using it depended on nothing — a big gap for SwiftUI and argument-parser-style code.
 - Swift Fluent relationship models are no longer orphaned. A type referenced only through a property-wrapper *argument* — `@Siblings(through: AcronymCategoryPivot.self, …)`, the many-to-many pivot/join model — now records a dependency on that type. Previously only the wrapper itself (`Siblings`) and the property's declared type were captured, so a pivot model reached solely through the relationship looked like nothing depended on it and editing it surfaced no impact. (Swift, Vapor/Fluent)
 - Java annotations are now connected. Annotation definitions (`@interface Foo`) are indexed as types, and every `@Foo` usage on a class, method, or field is recorded as a dependency on it. Previously neither side was captured — annotation usages were dropped (they live inside the declaration's modifiers) and `@interface` types weren't indexed at all — so annotation-driven code (Spring `@GetMapping`, JPA `@Entity`, Gson `@SerializedName`, …) showed the annotation as having no users and the annotated class as not depending on it.

+ 37 - 0
__tests__/extraction.test.ts

@@ -4360,6 +4360,43 @@ describe('SvelteKit load → page synthesizer', () => {
   });
 });
 
+describe('Nuxt nested auto-imported component resolution', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('links a `<MediaCard/>` usage to components/media/Card.vue (Nuxt dir-prefixed auto-import)', async () => {
+    // Nuxt auto-imports a nested component by a DIRECTORY-PREFIXED name —
+    // components/media/Card.vue is used as <MediaCard/>, not <Card/> — but the
+    // component node is named by basename (`Card`), so the PascalCase usage
+    // didn't resolve and the nested component looked unused.
+    const media = path.join(tempDir, 'components/media');
+    fs.mkdirSync(media, { recursive: true });
+    fs.writeFileSync(path.join(media, 'Card.vue'), `<template><div>card</div></template>\n<script setup>defineProps(['item'])</script>\n`);
+    fs.writeFileSync(
+      path.join(tempDir, 'components/Grid.vue'),
+      `<template>\n  <div><MediaCard :item="i" /></div>\n</template>\n<script setup>const i = {}</script>\n`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const card = cg.getNodesByKind('component').find((n) => n.filePath.endsWith('media/Card.vue'));
+    expect(card, 'media/Card.vue component').toBeDefined();
+    const deps = [...cg.getImpactRadius(card!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(deps.some((p) => p.endsWith('components/Grid.vue')), '<MediaCard> links Grid to media/Card.vue').toBe(true);
+  });
+});
+
 describe('Swift property-wrapper attribute type references', () => {
   let tempDir: string;
   let cg: CodeGraph;

+ 50 - 1
src/resolution/callback-synthesizer.ts

@@ -42,6 +42,10 @@ const MAX_JSX_CHILDREN = 30;
 // event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
 // are already caught by JSX_TAG_RE via the SFC component node.
 const VUE_KEBAB_RE = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g;
+// PascalCase component tags — `<MediaCard ...>`, `<NavBar/>`. HTML elements are
+// lowercase, so an uppercase-initial tag is a component usage; built-ins
+// (`<NuxtLink>`, `<Transition>`) simply resolve to nothing and emit no edge.
+const VUE_PASCAL_RE = /<([A-Z][A-Za-z0-9]*)[\s/>]/g;
 const VUE_HANDLER_RE = /(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)"/g;
 // Composable/hook destructure: `const { close: closeSidebar } = useSidebarControl()`.
 // Captures the destructure body + the called composable; only `use*` calls qualify.
@@ -64,6 +68,30 @@ function kebabToPascal(s: string): string {
   return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
 }
 
+/**
+ * Nuxt auto-import name for a component, derived from its path UNDER `components/`:
+ * `components/media/Card.vue` → `MediaCard`, `components/base/foo/Bar.vue` →
+ * `BaseFooBar`. Each directory segment and the filename is PascalCased and
+ * concatenated; a directory whose PascalCase name prefixes the next segment is
+ * collapsed (Nuxt's de-dup: `base/BaseButton.vue` → `BaseButton`, not
+ * `BaseBaseButton`). Returns null for a flat component (`components/NavBar.vue`)
+ * — its node is already named by basename, so a direct tag match finds it.
+ */
+function nuxtComponentName(filePath: string): string | null {
+  const marker = filePath.lastIndexOf('components/');
+  if (marker === -1) return null;
+  const rel = filePath.slice(marker + 'components/'.length).replace(/\.(vue|ts|tsx|js|jsx)$/i, '');
+  const segs = rel.split('/').filter(Boolean).map(kebabToPascal);
+  if (segs.length < 2) return null;
+  const out: string[] = [];
+  for (const s of segs) {
+    const prev = out[out.length - 1];
+    if (prev && s.startsWith(prev)) out[out.length - 1] = s;
+    else out.push(s);
+  }
+  return out.join('');
+}
+
 function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
   if (!startLine || !endLine) return null;
   return content.split('\n').slice(startLine - 1, endLine).join('\n');
@@ -801,6 +829,16 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
   // A composable's returned member may be a fn (`function close(){}`) or an
   // arrow assigned to a const (`const close = () => {}`).
   const RETURN_KINDS = new Set(['method', 'function', 'variable', 'constant']);
+  // Nuxt auto-imports nested components by a DIRECTORY-PREFIXED name —
+  // `components/media/Card.vue` is used as `<MediaCard/>`, not `<Card/>` — but
+  // the component node is named by basename (`Card`), so a direct tag match
+  // misses it (flat components match by basename and don't need this). Map each
+  // nested component's Nuxt name → node so those template usages resolve.
+  const nuxtComponents = new Map<string, Node>();
+  for (const c of ctx.getNodesByKind('component')) {
+    const nn = nuxtComponentName(c.filePath);
+    if (nn && !nuxtComponents.has(nn)) nuxtComponents.set(nn, c);
+  }
   for (const file of ctx.getAllFiles()) {
     if (!file.endsWith('.vue')) continue;
     const content = ctx.readFile(file);
@@ -842,7 +880,18 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
 
     let m: RegExpExecArray | null;
     VUE_KEBAB_RE.lastIndex = 0;
-    while ((m = VUE_KEBAB_RE.exec(tpl))) addEdge(resolve(kebabToPascal(m[1]!), COMPONENT_KINDS), { synthesizedBy: 'jsx-render', via: m[1] });
+    while ((m = VUE_KEBAB_RE.exec(tpl))) {
+      const tag = kebabToPascal(m[1]!);
+      addEdge(resolve(tag, COMPONENT_KINDS) ?? nuxtComponents.get(tag), { synthesizedBy: 'jsx-render', via: m[1] });
+    }
+    // PascalCase component tags. Try a direct name match first (flat components
+    // and explicit registrations), then the Nuxt dir-prefixed auto-import name
+    // (`<MediaCard>` → components/media/Card.vue). Built-ins match neither → no edge.
+    VUE_PASCAL_RE.lastIndex = 0;
+    while ((m = VUE_PASCAL_RE.exec(tpl))) {
+      const tag = m[1]!;
+      addEdge(resolve(tag, COMPONENT_KINDS) ?? nuxtComponents.get(tag), { synthesizedBy: 'jsx-render', via: tag });
+    }
     VUE_HANDLER_RE.lastIndex = 0;
     while ((m = VUE_HANDLER_RE.exec(tpl))) {
       const event = m[1]!;