Преглед изворни кода

feat(resolution): Vue SFC template coverage (events + kebab components)

The .vue extractor only parses <script>, so template usage is invisible —
handlers and kebab child components used only in <template> have no edge. Add a
vueTemplateEdges channel (scoped to the <template> block of .vue files):
- event bindings: @click="onClick" / v-on:submit="save" -> handler method/function
  (skips inline arrows and $emit; resolves same-file first to avoid cross-app
  mis-match in monorepos).
- kebab child components: <el-button> -> ElButton (PascalCase children like
  <VPNav/> are already caught by the JSX channel via the SFC component node).

Surface vue-handler in synthEdgeNote (trace/node trail) + context call-paths.

Validated on vue repos (reindex, no node explosion):
- vue-handler edges: vitepress 15, vben 404, element-plus 603 — all precise
  (code-login @submit -> handleLogin, register @submit -> handleSubmit, ...).
- callers(handleLogin) now includes the login component (was 0); each monorepo
  app's login resolves to its own same-file handler.
- composition: PascalCase + kebab work; element-plus's el-/filename naming
  (el-button -> button.vue) is a known library-prefix limitation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry пре 1 месец
родитељ
комит
3dbfedf165
3 измењених фајлова са 78 додато и 3 уклоњено
  1. 2 0
      src/context/index.ts
  2. 8 0
      src/mcp/tools.ts
  3. 68 3
      src/resolution/callback-synthesizer.ts

+ 2 - 0
src/context/index.ts

@@ -348,6 +348,8 @@ export class ContextBuilder {
         ? `React re-render via setState${at}`
         : m.synthesizedBy === 'jsx-render'
         ? `renders <${String(m.via || 'child')}>`
+        : m.synthesizedBy === 'vue-handler'
+        ? `Vue @${String(m.event || 'event')} handler`
         : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`;
       synthByPair.set(`${e.source}>${e.target}`, label);
     }

+ 8 - 0
src/mcp/tools.ts

@@ -1117,6 +1117,14 @@ export class ToolHandler {
         registeredAt,
       };
     }
+    if (m?.synthesizedBy === 'vue-handler') {
+      const ev = m.event ? `@${String(m.event)}` : 'a template event';
+      return {
+        label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
+        compact: `dynamic: Vue ${ev} handler`,
+        registeredAt,
+      };
+    }
     return null;
   }
 

+ 68 - 3
src/resolution/callback-synthesizer.ts

@@ -35,6 +35,15 @@ const EMIT_RE = /\.(?:emit|fire|dispatchEvent)\(\s*['"]([^'"]+)['"]/g;
 const SETSTATE_RE = /this\.setState\s*\(/;
 const JSX_TAG_RE = /<([A-Z][A-Za-z0-9_]*)[\s/>]/g;
 const MAX_JSX_CHILDREN = 30;
+// Vue SFC templates: kebab-case child components (<el-button> → ElButton) and
+// 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;
+const VUE_HANDLER_RE = /(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)"/g;
+
+function kebabToPascal(s: string): string {
+  return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
+}
 
 function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
   if (!startLine || !endLine) return null;
@@ -272,20 +281,76 @@ function reactJsxChildEdges(ctx: ResolutionContext): Edge[] {
   return edges;
 }
 
+/**
+ * Phase 6: Vue SFC templates. The `.vue` extractor only parses `<script>`, so
+ * template usage is invisible — child components and event handlers used ONLY in
+ * the template have no edge to them. PascalCase children (`<VPNav/>`) are already
+ * caught by reactJsxChildEdges (which scans the SFC component node), so this adds
+ * the two Vue-specific shapes:
+ *   - kebab-case children: `<el-button>` → `ElButton` component (renders).
+ *   - event bindings: `@click="onClick"` / `v-on:submit="save"` → handler method.
+ * Scoped to the `<template>` block of `.vue` files; resolution gate (kebab→
+ * component, handler→function/method) keeps precision; inline arrows / `$emit`
+ * skipped.
+ */
+function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  const COMPONENT_KINDS = new Set(['component', 'function', 'class']);
+  const HANDLER_KINDS = new Set(['method', 'function']);
+  for (const file of ctx.getAllFiles()) {
+    if (!file.endsWith('.vue')) continue;
+    const content = ctx.readFile(file);
+    const tpl = content && content.match(/<template[^>]*>([\s\S]*)<\/template>/i)?.[1];
+    if (!tpl) continue;
+    const comp = ctx.getNodesInFile(file).find((n) => n.kind === 'component');
+    if (!comp) continue;
+    let added = 0;
+    const link = (name: string, kinds: Set<string>, meta: Record<string, unknown>) => {
+      if (added >= MAX_JSX_CHILDREN) return;
+      const matches = ctx.getNodesByName(name).filter((n) => kinds.has(n.kind));
+      // Prefer a target in THIS SFC (handlers are defined in the same file's
+      // script) — avoids cross-file mis-resolution when a name repeats across a
+      // monorepo (e.g. 7 code-login.vue each with handleLogin). Child components
+      // live in other files, so they fall back to the first match.
+      const target = matches.find((n) => n.filePath === file) ?? matches[0];
+      if (!target || target.id === comp.id) return;
+      const key = `${comp.id}>${target.id}>${meta.synthesizedBy}`;
+      if (seen.has(key)) return;
+      seen.add(key);
+      edges.push({ source: comp.id, target: target.id, kind: 'calls', line: comp.startLine, provenance: 'heuristic', metadata: meta });
+      added++;
+    };
+    let m: RegExpExecArray | null;
+    VUE_KEBAB_RE.lastIndex = 0;
+    while ((m = VUE_KEBAB_RE.exec(tpl))) link(kebabToPascal(m[1]!), COMPONENT_KINDS, { synthesizedBy: 'jsx-render', via: m[1] });
+    VUE_HANDLER_RE.lastIndex = 0;
+    while ((m = VUE_HANDLER_RE.exec(tpl))) {
+      const event = m[1]!;
+      const expr = m[2]!.trim();
+      if (expr.includes('=>') || expr.startsWith('$')) continue; // inline arrow / $emit
+      const name = expr.match(/^([A-Za-z_]\w*)/)?.[1];
+      if (name) link(name, HANDLER_KINDS, { synthesizedBy: 'vue-handler', event });
+    }
+  }
+  return edges;
+}
+
 /**
  * Synthesize dispatcher→callback edges (field observers + EventEmitters +
- * React re-render + JSX children). Returns the count added. Never throws into
- * indexing — callers wrap in try/catch.
+ * React re-render + JSX children + Vue templates). Returns the count added.
+ * Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
   const fieldEdges = fieldChannelEdges(queries, ctx);
   const emitterEdges = eventEmitterEdges(ctx);
   const renderEdges = reactRenderEdges(queries, ctx);
   const jsxEdges = reactJsxChildEdges(ctx);
+  const vueEdges = vueTemplateEdges(ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
-  for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges]) {
+  for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges, ...vueEdges]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;
     seen.add(key);