From d39ee5a88a096164262c18dd5c7b0534559940a3 Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Sun, 31 May 2026 15:03:02 +0300 Subject: [PATCH 1/9] feat(vue): migrate Vue SFC to scope-based resolution (RFC #909 Ring 3, closes #940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `vueScopeResolver` and wires Vue into the scope-resolution pipeline (`SCOPE_RESOLVERS`, `MIGRATED_LANGUAGES`). Vue's ` diff --git a/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/PostList.vue b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/PostList.vue new file mode 100644 index 0000000000..5334b754e4 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/PostList.vue @@ -0,0 +1,33 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/UserProfile.vue b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/UserProfile.vue new file mode 100644 index 0000000000..208dd5ed5f --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/UserProfile.vue @@ -0,0 +1,43 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/api.ts b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/api.ts new file mode 100644 index 0000000000..6489105072 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/api.ts @@ -0,0 +1,18 @@ +import type { User, Post } from './types'; + +export async function fetchUser(id: number): Promise { + const response = await fetch(`/api/users/${id}`); + return response.json() as Promise; +} + +export async function fetchPosts(userId: number): Promise { + const response = await fetch(`/api/users/${userId}/posts`); + return response.json() as Promise; +} + +export function saveUser(user: User): Promise { + return fetch('/api/users', { + method: 'POST', + body: JSON.stringify(user), + }).then((r) => r.json() as Promise); +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/types.ts b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/types.ts new file mode 100644 index 0000000000..7661c225dd --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/types.ts @@ -0,0 +1,19 @@ +export interface User { + id: number; + name: string; + email: string; +} + +export interface Post { + id: number; + title: string; + authorId: number; +} + +export function formatUser(user: User): string { + return `${user.name} <${user.email}>`; +} + +export function formatPost(post: Post): string { + return `[${post.id}] ${post.title}`; +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/App.vue b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/App.vue new file mode 100644 index 0000000000..483e5a79ad --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/App.vue @@ -0,0 +1,20 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/PostCard.vue b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/PostCard.vue new file mode 100644 index 0000000000..63d82445f6 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/PostCard.vue @@ -0,0 +1,22 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/UserCard.vue b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/UserCard.vue new file mode 100644 index 0000000000..3a783113ad --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/UserCard.vue @@ -0,0 +1,23 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/usePost.ts b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/usePost.ts new file mode 100644 index 0000000000..85b336948b --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/usePost.ts @@ -0,0 +1,18 @@ +import { ref } from 'vue'; +import { PostModel } from '../models'; + +export function usePost() { + const post = ref(null); + + function loadPost(id: number): PostModel { + const p = new PostModel(id, 'Hello World', 'Content here', 1); + post.value = p; + return p; + } + + function getSummary(): string { + return post.value?.summary() ?? ''; + } + + return { post, loadPost, getSummary }; +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/useUser.ts b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/useUser.ts new file mode 100644 index 0000000000..fb50ac126a --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/useUser.ts @@ -0,0 +1,34 @@ +import { ref, computed } from 'vue'; +import type { Ref } from 'vue'; +import { UserModel } from '../models'; + +export function useUser(initialId: number) { + const user = ref(null); + const loading = ref(false); + + const isAdmin = computed(() => user.value?.isAdmin() ?? false); + + async function loadUser(id: number): Promise { + loading.value = true; + const u = new UserModel(id, 'Alice', 'admin'); + user.value = u; + loading.value = false; + return u; + } + + function getDisplayName(): string { + return user.value?.displayName() ?? 'Unknown'; + } + + return { user, loading, isAdmin, loadUser, getDisplayName }; +} + +export function useUserList(): { users: Ref; addUser: (u: UserModel) => void } { + const users = ref([]); + + function addUser(u: UserModel) { + users.value.push(u); + } + + return { users, addUser }; +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/models.ts b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/models.ts new file mode 100644 index 0000000000..d3f3ae17a8 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/models.ts @@ -0,0 +1,32 @@ +export class UserModel { + constructor( + public id: number, + public name: string, + public role: 'admin' | 'user', + ) {} + + isAdmin(): boolean { + return this.role === 'admin'; + } + + displayName(): string { + return `${this.name} (${this.role})`; + } +} + +export class PostModel { + constructor( + public id: number, + public title: string, + public content: string, + public authorId: number, + ) {} + + summary(): string { + return this.title.substring(0, 100); + } + + wordCount(): number { + return this.content.split(' ').length; + } +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-options-api/src/App.vue b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/App.vue new file mode 100644 index 0000000000..449772d021 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-options-api/src/Counter.vue b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/Counter.vue new file mode 100644 index 0000000000..edc0184244 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/Counter.vue @@ -0,0 +1,42 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-options-api/src/TodoList.vue b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/TodoList.vue new file mode 100644 index 0000000000..cca9ce1d86 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/TodoList.vue @@ -0,0 +1,51 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-options-api/src/utils.ts b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/utils.ts new file mode 100644 index 0000000000..19b5bcd1a6 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/utils.ts @@ -0,0 +1,21 @@ +export interface Todo { + id: number; + text: string; + done: boolean; +} + +export function createTodo(text: string): Todo { + return { id: Date.now(), text, done: false }; +} + +export function toggleTodo(todo: Todo): Todo { + return { ...todo, done: !todo.done }; +} + +export function filterDone(todos: Todo[]): Todo[] { + return todos.filter((t) => t.done); +} + +export function filterPending(todos: Todo[]): Todo[] { + return todos.filter((t) => !t.done); +} diff --git a/gitnexus/test/integration/resolvers/vue-scope.test.ts b/gitnexus/test/integration/resolvers/vue-scope.test.ts new file mode 100644 index 0000000000..1669c354a5 --- /dev/null +++ b/gitnexus/test/integration/resolvers/vue-scope.test.ts @@ -0,0 +1,388 @@ +/** + * Vue SFC: scope-based resolution (RFC #909 Ring 3, issue #940). + * + * Three fixture repos covering the main Vue SFC patterns: + * + * - vue-composition-api — ``, + ].join('\n'); + fs.writeFileSync(path.join(srcDir, `${name}.vue`), content); + } + + // App.vue — imports and renders all components + const imports = Array.from( + { length: componentCount }, + (_, i) => `import Comp${i + 1} from './Comp${i + 1}.vue';`, + ).join('\n'); + const template = Array.from( + { length: componentCount }, + (_, i) => ` `, + ).join('\n'); + const appContent = [ + ``, + ``, + ``, + ].join('\n'); + fs.writeFileSync(path.join(srcDir, 'App.vue'), appContent); + + return dir; +} + +async function runBenchmark(componentCount: number, budgetMs: number): Promise { + const dir = generateVueFixture(componentCount); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => + setTimeout( + () => + reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${componentCount} components`)), + budgetMs, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + return { + fileCount: componentCount + 2, // N components + utils.ts + App.vue + componentCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + }; + } finally { + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(results: BenchResult[]) { + console.log('\nVue SFC Pipeline Benchmark'); + console.log('┌────────────┬──────────┬───────────┬──────────┬───────┬───────┐'); + console.log('│ Components │ Files │ Time (ms) │ Heap MB │ Nodes │ Edges │'); + console.log('├────────────┼──────────┼───────────┼──────────┼───────┼───────┤'); + for (const r of results) { + console.log( + `│ ${String(r.componentCount).padStart(10)} │ ${String(r.fileCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`, + ); + } + console.log('└────────────┴──────────┴───────────┴──────────┴───────┴───────┘'); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / component_ratio):'); + for (let i = 1; i < results.length; i++) { + const compRatio = results[i].componentCount / results[i - 1].componentCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / compRatio; + console.log( + ` ${results[i - 1].componentCount} → ${results[i].componentCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +describe.skipIf(!BENCH_ENABLED)('Vue pipeline benchmark', () => { + it('scales with component count', async () => { + const scales = [10, 25, 50, 100]; + const results: BenchResult[] = []; + + for (const componentCount of scales) { + const result = await runBenchmark(componentCount, 120_000); + results.push(result); + console.log( + ` ${componentCount} components: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults(results); + + for (let i = 1; i < results.length; i++) { + const compRatio = results[i].componentCount / results[i - 1].componentCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + // Wall-clock is noisy; allow a generous upper bound. + expect(timeRatio / compRatio).toBeLessThan(4); + + // Node count grows linearly with component count (each component + // contributes a constant number of nodes: File + Function nodes + + // scope nodes). A large ratio here indicates accidental O(n²) growth + // (e.g. every component importing from every other component). + const nodeRatio = results[i].nodeCount / results[i - 1].nodeCount; + expect(nodeRatio / compRatio).toBeLessThan(1.5); + } + }, 600_000); +}); From d0ff425e40fe9e3ede5d064767fcbf4069d2d941 Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Mon, 1 Jun 2026 23:49:46 +0300 Subject: [PATCH 4/9] feat(vue): BINDS_EVENT_HANDLER/EMITS_EVENT edges via ScopeResolver hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer feedback on PR #1950: - Do not edit call-processor.ts (will be removed when all languages migrate) - Model Vue component-event system with dedicated edge types to avoid CALLS noise in deep component hierarchies (per contributor discussion) Changes: - gitnexus-shared: add BINDS_EVENT_HANDLER and EMITS_EVENT to RelationshipType - vue-sfc-extractor: add extractComponentEventBindings, extractNativeElementEventHandlers, and extractScriptEmitCalls - ScopeResolver contract: add optional emitPostResolutionEdges hook - run.ts: wire emitPostResolutionEdges after emitImportEdges - vue/scope-resolver: implement emitPostResolutionEdges emitting: 1. CALLS (vue-template-component) — PascalCase component File refs 2. CALLS (vue-template-callback) — @event on native HTML elements 3. BINDS_EVENT_HANDLER (vue-event: @name) — @event on component elements; source = handler fn in parent, target = child component File (not CALLS) 4. EMITS_EVENT (vue-emit: name) — emit() calls; self-loop on component File, joinable with BINDS_EVENT_HANDLER via Cypher for impact tracing 5. ACCESSES (vue-template-attribute) — :prop="var" bindings - call-processor.ts: revert dedicated Vue post-loop pass; moved to scope resolver - Tests and parity expected-failures updated accordingly Co-authored-by: Cursor --- gitnexus-shared/src/graph/types.ts | 20 ++- gitnexus/src/core/ingestion/call-processor.ts | 94 +---------- .../src/core/ingestion/languages/vue/index.ts | 15 +- .../ingestion/languages/vue/scope-resolver.ts | 135 ++++++++++++++++ .../contract/scope-resolver.ts | 33 ++++ .../scope-resolution/pipeline/run.ts | 10 ++ .../src/core/ingestion/vue-sfc-extractor.ts | 150 ++++++++++++++++++ .../test/integration/resolvers/helpers.ts | 14 +- .../integration/resolvers/vue-scope.test.ts | 36 +++-- 9 files changed, 393 insertions(+), 114 deletions(-) diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index d3dc816257..348d289600 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -115,7 +115,25 @@ export type RelationshipType = | 'HANDLES_TOOL' | 'ENTRY_POINT_OF' | 'WRAPS' - | 'QUERIES'; + | 'QUERIES' + /** Vue component event system: a handler function in a parent component is + * bound to an event emitted by a child component (`@event="handlerFn"`). + * Source = handler Function/Method node in the parent. + * Target = the child component's File node. + * `reason` encodes the event name: `vue-event: @`. + * Complements `EMITS_EVENT`; together they enable Cypher queries that + * trace which handlers receive which component's emitted events. */ + | 'BINDS_EVENT_HANDLER' + /** Vue component event system: a function inside a component calls + * `emit('eventName', ...)`, advertising that this component can emit + * that event. + * Source = Function/Method node containing the `emit()` call (falls + * back to the File node when the enclosing function cannot be determined). + * Target = the component's own File node (self-referential annotation). + * `reason` encodes the event name: `vue-emit: `. + * Complements `BINDS_EVENT_HANDLER`; a Cypher query joining on the + * component File node reveals all (emitter, handler) pairs. */ + | 'EMITS_EVENT'; export interface GraphNode { id: string; diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index bd6bdb13aa..c8c62921aa 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -85,11 +85,7 @@ import type { FileConstructorBindings, } from './workers/parse-worker.js'; import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js'; -import { - extractTemplateComponents, - extractTemplateEventHandlers, - extractTemplateAttributeBindings, -} from './vue-sfc-extractor.js'; +import { extractTemplateComponents } from './vue-sfc-extractor.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; @@ -1545,94 +1541,6 @@ export const processCalls = async ( ctx.clearCache(); } - // ── Vue template edges (registry-primary path) ────────────────────────── - // When Vue is registry-primary the main loop above skips Vue files entirely. - // The scope-based captures intentionally exclude template expressions - // (`vue/captures.ts:11–13`), so all three categories of template-derived - // edges are emitted here, unconditionally for every Vue file: - // - // 1. Component-reference CALLS (`vue-template-component`) - // PascalCase elements matched against the import map. - // 2. Event-handler CALLS (`vue-template-callback`) - // `@event="methodName"` → CALLS to the resolved in-file function. - // 3. Attribute-binding ACCESSES (`vue-template-attribute`) - // `:prop="varName"` → ACCESSES to the resolved in-file variable. - // - // None of these overlap with edges emitted by the scope-based resolution - // path, so there is no double-counting. - if (isRegistryPrimary(SupportedLanguages.Vue)) { - for (const file of files) { - const language = getLanguageFromFilename(file.path); - if (language !== SupportedLanguages.Vue) continue; - const fileId = generateId('File', file.path); - - // 1 — component-reference CALLS - const templateComponents = extractTemplateComponents(file.content); - if (templateComponents.length > 0) { - const importedFiles = ctx.importMap.get(file.path); - if (importedFiles) { - for (const componentName of templateComponents) { - for (const importedPath of importedFiles) { - if (!importedPath.endsWith('.vue')) continue; - const basename = importedPath.slice( - importedPath.lastIndexOf('/') + 1, - importedPath.lastIndexOf('.'), - ); - if (basename !== componentName) continue; - const targetFileId = generateId('File', importedPath); - if (graph.getNode(targetFileId)) { - graph.addRelationship({ - id: generateId('CALLS', `${fileId}:${componentName}->${targetFileId}`), - sourceId: fileId, - targetId: targetFileId, - type: 'CALLS', - confidence: 0.9, - reason: 'vue-template-component', - }); - } - break; - } - } - } - } - - // 2 — event-handler CALLS (@click="methodName") - const eventHandlers = extractTemplateEventHandlers(file.content); - for (const handlerName of eventHandlers) { - const resolved = ctx.resolve(handlerName, file.path); - if (!resolved) continue; - for (const candidate of resolved.candidates) { - if (candidate.type !== 'Function' && candidate.type !== 'Method') continue; - graph.addRelationship({ - id: generateId('CALLS', `${fileId}:@${handlerName}->${candidate.nodeId}`), - sourceId: fileId, - targetId: candidate.nodeId, - type: 'CALLS', - confidence: 0.9, - reason: 'vue-template-callback', - }); - } - } - - // 3 — attribute-binding ACCESSES (:prop="varName") - const boundVars = extractTemplateAttributeBindings(file.content); - for (const varName of boundVars) { - const resolved = ctx.resolve(varName, file.path); - if (!resolved) continue; - for (const candidate of resolved.candidates) { - graph.addRelationship({ - id: generateId('ACCESSES', `${fileId}:bind:${varName}->${candidate.nodeId}`), - sourceId: fileId, - targetId: candidate.nodeId, - type: 'ACCESSES', - confidence: 0.8, - reason: 'vue-template-attribute', - }); - } - } - } - } - // ── Resolve deferred write-access edges ── // All properties (including Ruby attr_accessor) are now registered. for (const pw of pendingWrites) { diff --git a/gitnexus/src/core/ingestion/languages/vue/index.ts b/gitnexus/src/core/ingestion/languages/vue/index.ts index f33c9b78ea..84d8632d83 100644 --- a/gitnexus/src/core/ingestion/languages/vue/index.ts +++ b/gitnexus/src/core/ingestion/languages/vue/index.ts @@ -18,11 +18,16 @@ * ## Known limitations * * 1. **Template expressions** — Full template AST parsing is not performed. - * A dedicated post-loop pass in `call-processor.ts` extracts three - * categories of template-derived edges via lightweight regex: - * - PascalCase component references → `vue-template-component` CALLS - * - `@event="methodName"` single-identifier handlers → `vue-template-callback` CALLS - * - `:prop="varName"` single-identifier bindings → `vue-template-attribute` ACCESSES + * `vueScopeResolver.emitPostResolutionEdges` extracts four categories of + * template-derived edges via lightweight regex, all emitted after standard + * scope-resolution passes complete: + * - PascalCase component references → `vue-template-component` `CALLS` + * - `@event="handler"` on **native** elements → `vue-template-callback` `CALLS` + * - `@event="handler"` on **component** elements → `vue-event: @` `BINDS_EVENT_HANDLER` + * - `:prop="varName"` single-identifier bindings → `vue-template-attribute` `ACCESSES` + * `BINDS_EVENT_HANDLER` and `EMITS_EVENT` are complementary "hanging" edges: + * a Cypher query joining on the shared component File node reveals which + * handlers receive which component's emitted events. * Complex inline expressions (`@click="toggle(item)"`, `{{ a + b }}`, * member-access bindings `:key="post.id"`) are intentionally excluded * because they cannot be resolved to a single call/access target without diff --git a/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts index 18ac968999..f8a1964ec2 100644 --- a/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts @@ -53,13 +53,22 @@ 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 { vueProvider } from '../vue.js'; import { loadTsconfigPaths } from '../../language-config.js'; import { typescriptArityCompatibility, typescriptMergeBindings } from '../typescript/index.js'; import { makeVueResolveImportTarget } from './import-target.js'; +import { + extractTemplateComponents, + extractComponentEventBindings, + extractNativeElementEventHandlers, + extractScriptEmitCalls, + extractTemplateAttributeBindings, +} from '../../vue-sfc-extractor.js'; const vueScopeResolver: ScopeResolver = { language: SupportedLanguages.Vue, @@ -99,6 +108,132 @@ const vueScopeResolver: ScopeResolver = { // Vue uses explicit imports for all external symbols; no global free- // call fallback needed (would produce spurious edges for built-ins). allowGlobalFreeCallFallback: false, + + /** + * Emit template-derived edges after standard scope-resolution passes. + * + * Four categories (all scoped to `.vue` files only): + * + * 1. **CALLS** (`vue-template-component`) + * PascalCase component elements → the imported component's File node. + * Source = the parent file (File node). + * + * 2. **BINDS_EVENT_HANDLER** (`vue-event: @`) + * `@event="handler"` on a PascalCase component element. + * Source = the handler Function/Method node in the parent file. + * Target = the child component's File node. + * + * 3. **EMITS_EVENT** (`vue-emit: `) + * `emit('eventName', …)` call inside the script block. + * Source = the file's own File node. + * Target = the same File node (self-referential annotation). + * These are "hanging" edges: a Cypher query joins them with + * BINDS_EVENT_HANDLER edges on the shared component File node to + * reveal which handlers receive which component's emitted events. + * + * 4. **ACCESSES** (`vue-template-attribute`) + * `:prop="varName"` bound-attribute references. + * Source = the file's File node. Target = resolved variable node. + */ + emitPostResolutionEdges(graph, parsedFiles, nodeLookup, indexes, ctx) { + for (const parsedFile of parsedFiles) { + if (!parsedFile.filePath.endsWith('.vue')) continue; + const content = ctx.fileContents.get(parsedFile.filePath); + if (!content) continue; + + const fileId = generateId('File', parsedFile.filePath); + + // Build localName → resolved targetFile from finalized import edges. + const importTargetByName = new Map(); + for (const [scopeId, edges] of indexes.imports) { + const scope = indexes.scopeTree.getScope(scopeId); + if (scope?.filePath !== parsedFile.filePath) continue; + for (const edge of edges) { + if (edge.targetFile !== null && edge.localName) { + importTargetByName.set(edge.localName, edge.targetFile); + } + } + } + + // 1 — Component-reference CALLS + for (const componentName of extractTemplateComponents(content)) { + const targetFile = importTargetByName.get(componentName); + if (!targetFile) continue; + const targetFileId = generateId('File', targetFile); + if (!graph.getNode(targetFileId)) continue; + graph.addRelationship({ + id: generateId('CALLS', `${fileId}:${componentName}->${targetFileId}`), + sourceId: fileId, + targetId: targetFileId, + type: 'CALLS', + confidence: 0.9, + reason: 'vue-template-component', + }); + } + + // 2 — Native-element event-handler CALLS (@click="method" on