GitNexus/gitnexus/test/integration/vue-pipeline-benchmark.test.ts
DuduPhudu c2b4ec6c31
feat(vue): migrate Vue SFC to scope-based resolution (RFC #909 Ring 3, closes #940) (#1950)
* feat(vue): migrate Vue SFC to scope-based resolution (RFC #909 Ring 3, closes #940)

Adds `vueScopeResolver` and wires Vue into the scope-resolution pipeline
(`SCOPE_RESOLVERS`, `MIGRATED_LANGUAGES`). Vue's `<script>` / `<script
setup>` blocks are TypeScript — `emitVueScopeCaptures` extracts the script
block via the existing `extractVueScript` utility and delegates to
`emitTsScopeCaptures`, keeping grammar identity consistent with the cached
tree the parse-worker already builds.

- `languages/vue/captures.ts`     — `emitVueScopeCaptures`
- `languages/vue/import-target.ts` — `makeVueResolveImportTarget` (TS
  resolver + tsconfig path-alias support; explicit `.vue` imports
  resolve via the exact-path branch)
- `languages/vue/scope-resolver.ts` — `vueScopeResolver`
- `languages/vue/index.ts`         — barrel + known-limitations doc

- `languages/vue.ts`                  — `emitScopeCaptures` hooked up
- `scope-resolution/pipeline/registry.ts` — Vue entry added
- `registry-primary-flag.ts`          — `SupportedLanguages.Vue` added
  to `MIGRATED_LANGUAGES` (production default → registry-primary)

- `vue-composition-api` — `<script setup lang="ts">`, defineProps /
  defineEmits macros, cross-file TS imports, computed refs
- `vue-options-api`     — `defineComponent({methods, computed, data})`,
  this-based method calls, imported utility calls
- `vue-cross-file`      — composable functions returning class instances,
  multi-level import chains, UserModel/PostModel method calls

- `fieldFallbackOnMethodLookup: true` — Options API `this.X()` calls may
  not resolve through the type-binding layer (no formal class); fallback
  catches common patterns via declared field names.
- `allowGlobalFreeCallFallback: false` — Vue uses explicit imports;
  workspace-wide unique-name fallback would produce spurious edges for
  built-ins (ref, reactive, defineProps, …).
- Template expression calls intentionally out of scope: component-
  reference CALLS edges are already emitted by the legacy template
  extractor. Remaining template gaps tracked in #1647.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vue): address P0/P1 review findings from #1950

## P0 #1 — missing scope-resolution hooks in vueProvider
`pass3CollectImports` early-returns when `interpretImport` is undefined,
producing zero IMPORTS and zero cross-file CALLS edges. Add the four
hooks to `vueProvider` in `vue.ts`:
  - `interpretImport: interpretTsImport`
  - `interpretTypeBinding: interpretTsTypeBinding`
  - `bindingScopeFor: tsBindingScopeFor`
  - `importOwningScope: tsImportOwningScope`
Also add `receiverBinding`, `mergeBindings`, `arityCompatibility`, and
`resolveImportTarget` to complete the scope-resolution contract.

## P0 #2 — template-component CALLS dropped when Vue is registry-primary
`isRegistryPrimary(Vue) → true` makes the main call-processor loop skip
Vue files entirely, silencing the inline `vue-template-component` CALLS
emitter at ≈L1506. Add a dedicated post-loop pass in `call-processor.ts`
that emits template-component CALLS for Vue files whenever Vue is
registry-primary. Update the stale `vue/index.ts` limitation comment to
reflect the new emit site.

## P1 #3 — worker-mode double-extraction → zero captures
In worker mode (≥15 files) the parse worker pre-extracts the `<script>`
block and passes `scriptContent` as `sourceText`. `emitVueScopeCaptures`
was calling `extractVueScript` a second time, getting null, and returning
`[]`. Fix: if extraction returns null and the content has no SFC block-
level markers (`<template`, `<style`), treat it as already-extracted
script text and delegate directly to `emitTsScopeCaptures`.

## Test assertion strictness
Replace all `toBeGreaterThanOrEqual(1)` assertions with exact `toBe(N)`
counts. IMPORTS counts reflect per-symbol scope-based edges (value imports
only; `import type` is not emitted as an IMPORTS edge). CALLS counts are
1 per single-call-site.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(vue): template-derived edges + pipeline benchmark (#1950 review)

Addresses the reviewer's request for template edge attribution and a
performance benchmark.

## Template event-handler CALLS (`vue-template-callback`)
Add `extractTemplateEventHandlers` to `vue-sfc-extractor.ts`. Extracts
bare single-identifier handlers from `@event="methodName"` and
`v-on:event="methodName"` attributes. Inline expressions with arguments
or operators (`@click="toggle(item)"`) are intentionally excluded.

Wire into the dedicated registry-primary Vue template pass in
`call-processor.ts`. For each extracted handler name, `ctx.resolve`
finds the in-file Function/Method node and emits a CALLS edge with
`reason: 'vue-template-callback'`.

## Template attribute-binding ACCESSES (`vue-template-attribute`)
Add `extractTemplateAttributeBindings` to `vue-sfc-extractor.ts`.
Extracts bare single-identifier values from `:prop="varName"` and
`v-bind:prop="varName"` bindings. Member-access (`:key="post.id"`) and
literals are excluded by the identifier-boundary regex.

Wire into the same template pass. For each extracted variable, `ctx.resolve`
finds the in-file node and emits an ACCESSES edge with
`reason: 'vue-template-attribute'`.

## `vue/index.ts` limitations comment
Updated to accurately describe all three categories of template-derived
edges and explicitly document the complex-expression exclusions.

## Tests
Add 6 new assertions in `vue-scope.test.ts`:
- `@click="handleSave"` → CALLS `handleSave` (UserProfile.vue)
- `@select="onPostSelected"` → CALLS `onPostSelected` (App.vue composition)
- `@keyup.enter="addTodo"` → CALLS `addTodo` (TodoList.vue)
- `@loaded="onUserLoaded"` → CALLS `onUserLoaded` (App.vue cross-file)
- `:userId="currentUserId"` → ACCESSES `currentUserId` (App.vue composition)
- `:posts="allPosts"` → ACCESSES `allPosts` (App.vue composition)

Add `vue` entry to `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES` in
`helpers.ts` documenting which assertions are registry-primary-only
(IMPORTS cardinality, template-derived edges, `<script setup>` export).

## Benchmark
Add `vue-pipeline-benchmark.test.ts` (gated by `GITNEXUS_BENCH=1`).
Generates N-component synthetic repos (10 / 25 / 50 / 100) and asserts
that wall-clock and node counts scale sub-quadratically with component
count, guarding against O(n²) regressions in the template extraction
or scope-resolution passes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(vue): BINDS_EVENT_HANDLER/EMITS_EVENT edges via ScopeResolver hook

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 <cursoragent@cursor.com>

* fix(vue): close review gaps in scope/parity extraction

Resolve the new PR #1950 review findings by widening Vue scope context to include TS/JS import closures, fixing BINDS_EVENT_HANDLER endpoint assertions, hardening emit/event extraction to avoid comment/property false positives, supporting kebab-case component tags, and ensuring parity runs include vue-scope suites.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vue): address second review round — regex safety, emit coverage, arch

Closes items raised in the Jun 2 review comment on PR #1950.

Correctness fixes:
- ReDoS mitigation: bound attribute-capture spans to [^>]{0,512}? in all
  three template tag regexes to prevent pathological backtracking.
- Kebab-case misclassified as native: added (?![A-Za-z0-9-]) negative
  lookahead to NATIVE_TAG_RE so <post-list> is no longer split as native
  tag `post` with attrs `-list ...`.
- Hyphenated event names dropped: widened TAG_EVENT_RE from [\w:.]+ to
  [\w:.-]+ so @user-loaded and @update:model-value are captured.
- this.$emit silently dropped: collectBareEmitEventNames now allows
  this.$emit(...) by looking back past the '.' to verify preceding token
  is exactly `this`; socket.emit etc. remain blocked.
- Event names with colon rejected: extended validator to accept
  update:modelValue and update:model-value patterns.

Architecture fix:
- Moved collectVueScopeFilePaths out of shared phase.ts into a new
  collectScopeContextPaths optional hook on ScopeResolver, keeping shared
  pipeline code language-agnostic. vueScopeResolver implements the hook.
- Fixed memory leak: preExtractedByPath cleanup now iterates filePaths
  (all context files) not just primaryFilePaths (only .vue files).

Cleanup:
- Removed unused extractTemplateEventHandlers and duplicate EVENT_HANDLER_RE.
- Fixed skipped comment numbers in emitPostResolutionEdges (1,2,4,5,6 -> 1-6).
- Updated vue/index.ts: four categories -> five (added EMITS_EVENT).
- Fixed gitnexus-shared EMITS_EVENT JSDoc to reflect File->File reality.

Tests: 7 new unit tests covering hyphenated events, this.$emit, kebab-case
native-tag exclusion, and update:modelValue event name validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vue): eliminate double file-read and per-file template re-scans

Two performance fixes from the self-review pass:

1. **No more double read of .vue files in phase.ts**: primary files were
   previously read once for `collectScopeContextPaths` (via
   `entryFileContents`) and again in the blanket `readFileContents(filePaths)`
   call. Now the primary-file map is passed directly and only the extra
   context files (TS/JS import closure) require a second I/O round-trip.

2. **Single template parse per .vue file in emitPostResolutionEdges**:
   previously each of the five extractor functions (components, native
   handlers, component event bindings, emit calls, attribute bindings) ran
   `TEMPLATE_RE.exec(content)` independently — five full-file scans per
   `.vue` file. Replaced with a new `extractVueTemplateEdgeData` batching
   helper that parses the template and script blocks once and feeds all five
   extractors from the pre-extracted content. emitPostResolutionEdges now
   calls a single function and destructures the results.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(parity): exclude TypeScript HOC/HOF/JSX scope-resolver tests from legacy DAG parity gate

Three test files introduced in prior PRs exercise scope-resolver-only
correctness wins: HOC-wrapped const declarations, HOF-callback caller
attribution, and JSX-as-call CALLS edges. The parity runner's
${slug}-*.test.ts glob now picks them up, causing typescript [legacy]
failures in CI.

Fix: convert each file to use createResolverParityIt('typescript') and
register all 26 legacy-failing test names in
LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.typescript with explanatory
comments. Legacy mode: 11+11+4 tests skipped, zero failures.
Registry-primary mode: all 37 tests pass as before.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(test): remove registry-primary-flag unit tests after migration complete

All languages are now in MIGRATED_LANGUAGES; the per-language flip
tests are no longer needed. Addresses PR #1950 review feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-03 21:48:38 +01:00

199 lines
7.5 KiB
TypeScript

/**
* Vue SFC ingestion pipeline benchmark.
*
* Generates synthetic Vue codebases at increasing scales and measures
* wall-clock time and peak heap through the full pipeline — scanning,
* SFC script extraction, scope-based resolution, template-edge emission,
* and graph build.
*
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/vue-pipeline-benchmark.test.ts
*
* Each synthetic repo contains:
* - A shared `utils.ts` exporting one utility function per component
* - N `.vue` SFC files, each with a `<script setup>` importing from
* `utils.ts` and one event-handler binding in the template
* - An `App.vue` that imports and renders all components via props/events
*
* Per-component work is intentionally constant as `fileCount` grows.
* The node-ratio assertion below guards against accidental O(n²) patterns
* (e.g. every component importing from every other component).
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
interface BenchResult {
fileCount: number;
componentCount: number;
elapsedMs: number;
peakHeapMB: number;
nodeCount: number;
edgeCount: number;
}
function generateVueFixture(componentCount: number): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `vue-bench-${componentCount}-`));
const srcDir = path.join(dir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
// Shared utils.ts — one exported function per component
const utilExports = Array.from(
{ length: componentCount },
(_, i) => `export function util${i + 1}(x: string): string { return x + '${i + 1}'; }`,
).join('\n');
fs.writeFileSync(path.join(srcDir, 'utils.ts'), utilExports + '\n');
// Generate N .vue components
for (let i = 1; i <= componentCount; i++) {
const name = `Comp${i}`;
const content = [
`<template>`,
` <div class="${name.toLowerCase()}">`,
` <p>{{ label }}</p>`,
` <button @click="handleClick">Action ${i}</button>`,
` </div>`,
`</template>`,
``,
`<script setup lang="ts">`,
`import { ref } from 'vue';`,
`import { util${i} } from './utils';`,
``,
`const props = defineProps<{ value: string }>();`,
`const label = ref(util${i}(props.value));`,
``,
`function handleClick() {`,
` label.value = util${i}(label.value);`,
`}`,
`</script>`,
].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) => ` <Comp${i + 1} :value="items[${i}]" @update="onUpdate" />`,
).join('\n');
const appContent = [
`<template>`,
` <div id="app">`,
template,
` </div>`,
`</template>`,
``,
`<script setup lang="ts">`,
`import { ref } from 'vue';`,
imports,
``,
`const items = ref(Array.from({ length: ${componentCount} }, (_, i) => String(i)));`,
``,
`function onUpdate(val: string) {`,
` console.log(val);`,
`}`,
`</script>`,
].join('\n');
fs.writeFileSync(path.join(srcDir, 'App.vue'), appContent);
return dir;
}
async function runBenchmark(componentCount: number, budgetMs: number): Promise<BenchResult> {
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<never>((_, 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);
});