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

feat(resolution): resolve Vue composable-destructure template handlers

@click="closeSidebar" where `const { close: closeSidebar } = useSidebarControl()`
previously didn't resolve — the handler is a destructured composable return, not a
local fn node. Now: parse the SFC's `use*()` destructures into alias→{composable,
key}, and for an unresolved template handler follow alias → composable → the
returned member (`close`) defined in the composable's file. Precise-only: no
fallback to the composable itself (the component already has a static useX() call
edge), so we add an edge only when the specific returned fn is found.

Validated: vitepress Layout @click→close / @open-menu→open (in composables/
sidebar.ts); sidebar-flow agent run dropped 6→0 reads (best case). element-plus's
fallback-only matches correctly drop to 0; node counts stable; direct handlers
(vben handleLogin) unaffected.

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

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

@@ -40,6 +40,9 @@ const MAX_JSX_CHILDREN = 30;
 // 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;
+// Composable/hook destructure: `const { close: closeSidebar } = useSidebarControl()`.
+// Captures the destructure body + the called composable; only `use*` calls qualify.
+const VUE_DESTRUCTURE_RE = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*(\w+)\s*\(/g;
 
 function kebabToPascal(s: string): string {
   return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
@@ -298,6 +301,9 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
   const seen = new Set<string>();
   const COMPONENT_KINDS = new Set(['component', 'function', 'class']);
   const HANDLER_KINDS = new Set(['method', 'function']);
+  // 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']);
   for (const file of ctx.getAllFiles()) {
     if (!file.endsWith('.vue')) continue;
     const content = ctx.readFile(file);
@@ -305,32 +311,62 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
     if (!tpl) continue;
     const comp = ctx.getNodesInFile(file).find((n) => n.kind === 'component');
     if (!comp) continue;
+
+    // Composable-destructure map: alias → { composable, key }. Lets us resolve a
+    // template handler that isn't a local function but a destructured composable
+    // return (`@click="closeSidebar"` ← `const { close: closeSidebar } = useSidebarControl()`).
+    const script = content.match(/<script[^>]*>([\s\S]*?)<\/script>/i)?.[1] ?? '';
+    const destructured = new Map<string, { composable: string; key: string }>();
+    VUE_DESTRUCTURE_RE.lastIndex = 0;
+    let dm: RegExpExecArray | null;
+    while ((dm = VUE_DESTRUCTURE_RE.exec(script))) {
+      if (!/^use[A-Z]/.test(dm[2]!)) continue; // composables / hooks only
+      for (const part of dm[1]!.split(',')) {
+        const pm = part.trim().match(/^(\w+)\s*(?::\s*(\w+))?$/); // key | key: alias
+        if (pm) destructured.set(pm[2] || pm[1]!, { composable: dm[2]!, key: pm[1]! });
+      }
+    }
+
     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);
+    const addEdge = (target: Node | undefined, meta: Record<string, unknown>) => {
+      if (added >= MAX_JSX_CHILDREN || !target || target.id === comp.id) return;
+      const k = `${comp.id}>${target.id}>${meta.synthesizedBy}`;
+      if (seen.has(k)) return;
+      seen.add(k);
       edges.push({ source: comp.id, target: target.id, kind: 'calls', line: comp.startLine, provenance: 'heuristic', metadata: meta });
       added++;
     };
+    // Prefer a target in THIS SFC (handlers live in the same file's script) —
+    // avoids cross-file mis-match when a name repeats across a monorepo.
+    const resolve = (name: string, kinds: Set<string>): Node | undefined => {
+      const matches = ctx.getNodesByName(name).filter((n) => kinds.has(n.kind));
+      return matches.find((n) => n.filePath === file) ?? matches[0];
+    };
+
     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] });
+    while ((m = VUE_KEBAB_RE.exec(tpl))) addEdge(resolve(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 });
+      if (!name) continue;
+      const direct = resolve(name, HANDLER_KINDS);
+      if (direct) { addEdge(direct, { synthesizedBy: 'vue-handler', event }); continue; }
+      // Composable-destructure handler → resolve to the composable's returned fn.
+      const d = destructured.get(name);
+      if (!d) continue;
+      const composable = resolve(d.composable, HANDLER_KINDS);
+      // Resolve to the SPECIFIC returned member (e.g. `close`) defined in the
+      // composable's file. No fallback to the composable itself — the component
+      // already has a static `useX()` call edge, so that would just be redundant
+      // and less precise.
+      const keyFn = composable
+        ? ctx.getNodesByName(d.key).find((n) => RETURN_KINDS.has(n.kind) && n.filePath === composable.filePath)
+        : undefined;
+      if (keyFn) addEdge(keyFn, { synthesizedBy: 'vue-handler', event, via: d.composable });
     }
   }
   return edges;