mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
* 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>
199 lines
7.5 KiB
TypeScript
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);
|
|
});
|