mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +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>
This commit is contained in:
parent
226bd27cd0
commit
c2b4ec6c31
39 changed files with 2522 additions and 207 deletions
|
|
@ -115,7 +115,23 @@ 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: @<eventName>`.
|
||||
* 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 component calls `emit('eventName', ...)`
|
||||
* or `this.$emit('eventName', ...)`, advertising that it can emit that event.
|
||||
* Source = the component's own File node (self-referential annotation).
|
||||
* Target = the same File node.
|
||||
* `reason` encodes the event name: `vue-emit: <eventName>`.
|
||||
* 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;
|
||||
|
|
|
|||
|
|
@ -41,8 +41,15 @@ function envVarName(slug: string): string {
|
|||
return `REGISTRY_PRIMARY_${slug.toUpperCase().replace(/-/g, '_')}`;
|
||||
}
|
||||
|
||||
function testFilePath(slug: string): string {
|
||||
return `test/integration/resolvers/${slug}.test.ts`;
|
||||
function testFilePaths(slug: string): string[] {
|
||||
const resolverDir = path.resolve(ROOT, 'test/integration/resolvers');
|
||||
const files = fs.readdirSync(resolverDir);
|
||||
const direct = `${slug}.test.ts`;
|
||||
const prefixed = `${slug}-`;
|
||||
return files
|
||||
.filter((name) => name === direct || (name.startsWith(prefixed) && name.endsWith('.test.ts')))
|
||||
.sort()
|
||||
.map((name) => `test/integration/resolvers/${name}`);
|
||||
}
|
||||
|
||||
function runVitest(testFile: string, env: Record<string, string>): boolean {
|
||||
|
|
@ -73,12 +80,12 @@ const languages = singleLang ? [singleLang] : [...MIGRATED_LANGUAGES].map(String
|
|||
|
||||
// Verify test files exist before running
|
||||
const missingFiles: string[] = [];
|
||||
const filesByLanguage = new Map<string, string[]>();
|
||||
for (const lang of languages) {
|
||||
const file = path.resolve(ROOT, testFilePath(lang));
|
||||
try {
|
||||
fs.accessSync(file);
|
||||
} catch {
|
||||
missingFiles.push(`${testFilePath(lang)} (${lang})`);
|
||||
const files = testFilePaths(lang);
|
||||
filesByLanguage.set(lang, files);
|
||||
if (files.length === 0) {
|
||||
missingFiles.push(`test/integration/resolvers/${lang}*.test.ts (${lang})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,22 +101,26 @@ console.log(`Languages: ${languages.join(', ')}\n`);
|
|||
const failures: ParityFailure[] = [];
|
||||
|
||||
for (const lang of languages) {
|
||||
const file = testFilePath(lang);
|
||||
const files = filesByLanguage.get(lang) ?? [];
|
||||
const envVar = envVarName(lang);
|
||||
|
||||
console.log(`\n── ${lang} — legacy DAG (${envVar}=0) ──`);
|
||||
if (!runVitest(file, { [envVar]: '0' })) {
|
||||
failures.push({ lang, mode: 'legacy' });
|
||||
for (const file of files) {
|
||||
if (!runVitest(file, { [envVar]: '0' })) {
|
||||
failures.push({ lang, mode: 'legacy' });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n── ${lang} — registry-primary (${envVar}=1) ──`);
|
||||
if (!runVitest(file, { [envVar]: '1' })) {
|
||||
failures.push({ lang, mode: 'registry-primary' });
|
||||
for (const file of files) {
|
||||
if (!runVitest(file, { [envVar]: '1' })) {
|
||||
failures.push({ lang, mode: 'registry-primary' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const total = languages.length * 2;
|
||||
const total = [...filesByLanguage.values()].reduce((sum, files) => sum + files.length * 2, 0);
|
||||
const passed = total - failures.length;
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
|
|
|
|||
|
|
@ -426,6 +426,19 @@ interface LanguageProviderConfig {
|
|||
* MUST trigger a fresh parse.
|
||||
*/
|
||||
cachedTree?: unknown,
|
||||
/**
|
||||
* Optional metadata about how `sourceText` was produced.
|
||||
*
|
||||
* Most providers ignore this and treat `sourceText` as full file content.
|
||||
* Vue uses it to distinguish:
|
||||
* - `full-file`: full `.vue` SFC source
|
||||
* - `pre-extracted-script`: worker-preprocessed bare `<script>` content
|
||||
*
|
||||
* Default: `{ sourceKind: 'full-file' }`.
|
||||
*/
|
||||
sourceMeta?: {
|
||||
readonly sourceKind?: 'full-file' | 'pre-extracted-script';
|
||||
},
|
||||
) => readonly CaptureMatch[];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,6 +28,17 @@ import { typescriptVariableConfig } from '../variable-extractors/configs/typescr
|
|||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { typescriptCallConfig } from '../call-extractors/configs/typescript-javascript.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
interpretTsImport,
|
||||
interpretTsTypeBinding,
|
||||
tsBindingScopeFor,
|
||||
tsImportOwningScope,
|
||||
tsReceiverBinding,
|
||||
typescriptMergeBindings,
|
||||
typescriptArityCompatibility,
|
||||
resolveTsImportTarget,
|
||||
} from './typescript/index.js';
|
||||
import { emitVueScopeCaptures } from './vue/captures.js';
|
||||
|
||||
const VUE_SPECIFIC_BUILT_INS = [
|
||||
'ref',
|
||||
|
|
@ -81,4 +92,14 @@ export const vueProvider = defineLanguage({
|
|||
classExtractor: vueClassExtractor,
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.TypeScript),
|
||||
builtInNames: VUE_BUILT_INS,
|
||||
// Scope-resolution pipeline hooks (RFC #909 Ring 3)
|
||||
emitScopeCaptures: emitVueScopeCaptures,
|
||||
interpretImport: interpretTsImport,
|
||||
interpretTypeBinding: interpretTsTypeBinding,
|
||||
bindingScopeFor: tsBindingScopeFor,
|
||||
importOwningScope: tsImportOwningScope,
|
||||
receiverBinding: tsReceiverBinding,
|
||||
mergeBindings: (_scope, bindings) => typescriptMergeBindings(bindings),
|
||||
arityCompatibility: typescriptArityCompatibility,
|
||||
resolveImportTarget: resolveTsImportTarget,
|
||||
});
|
||||
|
|
|
|||
67
gitnexus/src/core/ingestion/languages/vue/captures.ts
Normal file
67
gitnexus/src/core/ingestion/languages/vue/captures.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Vue SFC scope captures (RFC #909 Ring 3, issue #940).
|
||||
*
|
||||
* Extracts the `<script>` / `<script setup>` block from the SFC source
|
||||
* and delegates to `emitTsScopeCaptures`. The parse-worker builds the
|
||||
* cached tree from the extracted script content using the TypeScript
|
||||
* grammar (see `[SupportedLanguages.Vue]: TypeScript.typescript` in
|
||||
* `parse-worker.ts`), so passing that tree here keeps grammar identity
|
||||
* consistent and avoids a redundant re-parse.
|
||||
*
|
||||
* Template expressions are intentionally out-of-scope: component-
|
||||
* reference CALLS edges are already emitted by the legacy template
|
||||
* extractor in the parse worker and would be double-counted here.
|
||||
*
|
||||
* Position note: all capture positions are relative to the *extracted*
|
||||
* script block, not the full .vue file. This is consistent with the
|
||||
* cached tree and with how the scope model uses positions (only for
|
||||
* scope-containment walks within a single file), so no offset
|
||||
* translation is required for graph-edge correctness.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { extractVueScript } from '../../vue-sfc-extractor.js';
|
||||
import { emitTsScopeCaptures } from '../typescript/captures.js';
|
||||
|
||||
/**
|
||||
* Emit scope captures for a Vue SFC.
|
||||
*
|
||||
* Handles three call-site shapes:
|
||||
*
|
||||
* 1. **Full SFC content** (sequential path, <15 files): `sourceText`
|
||||
* contains the whole `.vue` file with `<template>`, `<script>`, etc.
|
||||
* `extractVueScript` extracts the script block and we delegate to
|
||||
* `emitTsScopeCaptures` with that extracted content.
|
||||
*
|
||||
* 2. **Already-extracted script content** (worker-mode path, ≥15 files):
|
||||
* the parse worker calls `extractVueScript` itself before calling
|
||||
* `extractParsedFile`, so `sourceText` is already the bare TypeScript
|
||||
* text with no `<script>` tags. The caller marks this explicitly via
|
||||
* `sourceMeta.sourceKind === 'pre-extracted-script'`.
|
||||
*
|
||||
* 3. **Supporting TS/JS files** included in Vue scope-resolution runs:
|
||||
* when `filePath` is not `.vue`, delegate straight to TypeScript captures.
|
||||
*
|
||||
* Returns an empty array for render-function-only SFCs (no `<script>` block).
|
||||
*/
|
||||
export function emitVueScopeCaptures(
|
||||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
sourceMeta?: { sourceKind?: 'full-file' | 'pre-extracted-script' },
|
||||
): readonly CaptureMatch[] {
|
||||
// Vue resolver may include supporting TS/JS files in the same run to
|
||||
// preserve cross-file import/type context for `.vue` callers. These are
|
||||
// already plain script files, so no SFC extraction is needed.
|
||||
if (!filePath.endsWith('.vue')) {
|
||||
return emitTsScopeCaptures(sourceText, filePath, cachedTree);
|
||||
}
|
||||
|
||||
if (sourceMeta?.sourceKind === 'pre-extracted-script') {
|
||||
return emitTsScopeCaptures(sourceText, filePath, cachedTree);
|
||||
}
|
||||
|
||||
const extracted = extractVueScript(sourceText);
|
||||
if (extracted === null) return [];
|
||||
return emitTsScopeCaptures(extracted.scriptContent, filePath, cachedTree);
|
||||
}
|
||||
81
gitnexus/src/core/ingestion/languages/vue/import-target.ts
Normal file
81
gitnexus/src/core/ingestion/languages/vue/import-target.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Import-target resolver for Vue SFCs (RFC #909 Ring 3, issue #940).
|
||||
*
|
||||
* Vue `<script>` / `<script setup>` blocks are TypeScript (or plain
|
||||
* JavaScript), so the resolver delegates to `resolveTsTarget` with
|
||||
* `language: SupportedLanguages.TypeScript` to get:
|
||||
*
|
||||
* - tsconfig path-alias rewriting (Vue projects universally use TS)
|
||||
* - `.ts` / `.tsx` / `.js` / `.jsx` extension-suffix fallback
|
||||
*
|
||||
* `.vue` imports are written with explicit extensions (`'./Button.vue'`),
|
||||
* so no Vue-specific suffix guessing is required: the standard
|
||||
* resolver finds them via the exact-path branch before any extension
|
||||
* logic fires.
|
||||
*
|
||||
* Memoization mirrors the TypeScript adapter: workspace file-list
|
||||
* arrays, the suffix index, and the per-pass resolve cache are rebuilt
|
||||
* lazily when `allFilePaths` reference changes (once per workspace pass).
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { resolveTsTarget, type TsResolveContext } from '../typescript/import-target.js';
|
||||
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
|
||||
import type { TsconfigPaths } from '../../language-config.js';
|
||||
|
||||
interface VueResolutionConfig {
|
||||
readonly tsconfigPaths: TsconfigPaths | null;
|
||||
}
|
||||
|
||||
interface PassCache {
|
||||
readonly key: ReadonlySet<string>;
|
||||
readonly allFilePaths: Set<string>;
|
||||
readonly allFileList: readonly string[];
|
||||
readonly normalizedFileList: readonly string[];
|
||||
readonly index: SuffixIndex;
|
||||
readonly resolveCache: Map<string, string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a memoized `resolveImportTarget` adapter for Vue SFCs.
|
||||
*
|
||||
* Uses `SupportedLanguages.TypeScript` so tsconfig path-alias resolution
|
||||
* and `.ts`/`.tsx` extension guessing fire for relative and bare-specifier
|
||||
* imports inside `<script>` blocks.
|
||||
*/
|
||||
export function makeVueResolveImportTarget(): (
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
) => string | readonly string[] | null {
|
||||
let cached: PassCache | null = null;
|
||||
|
||||
return (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
|
||||
if (cached === null || cached.key !== allFilePaths) {
|
||||
const allFileList = Array.from(allFilePaths);
|
||||
const normalizedFileList = allFileList.map((f) => f.toLowerCase());
|
||||
cached = {
|
||||
key: allFilePaths,
|
||||
allFilePaths: new Set(allFilePaths),
|
||||
allFileList,
|
||||
normalizedFileList,
|
||||
index: buildSuffixIndex(normalizedFileList, allFileList),
|
||||
resolveCache: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const cfg = resolutionConfig as VueResolutionConfig | undefined;
|
||||
const ws: TsResolveContext = {
|
||||
fromFile,
|
||||
language: SupportedLanguages.TypeScript,
|
||||
allFilePaths: cached.allFilePaths,
|
||||
allFileList: cached.allFileList,
|
||||
normalizedFileList: cached.normalizedFileList,
|
||||
index: cached.index,
|
||||
resolveCache: cached.resolveCache,
|
||||
tsconfigPaths: cfg?.tsconfigPaths ?? null,
|
||||
};
|
||||
return resolveTsTarget(targetRaw, ws);
|
||||
};
|
||||
}
|
||||
50
gitnexus/src/core/ingestion/languages/vue/index.ts
Normal file
50
gitnexus/src/core/ingestion/languages/vue/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Vue SFC scope-resolution hooks (RFC #909 Ring 3, issue #940).
|
||||
*
|
||||
* Public API barrel. Consumers should import from this file rather
|
||||
* than the individual modules.
|
||||
*
|
||||
* Module layout (each file is a single concern):
|
||||
*
|
||||
* - `captures.ts` — `emitVueScopeCaptures` — extracts the
|
||||
* `<script>` / `<script setup>` block and
|
||||
* delegates to `emitTsScopeCaptures` (TypeScript
|
||||
* grammar, same grammar the parse-worker uses).
|
||||
* - `import-target.ts` — `makeVueResolveImportTarget` — memoized
|
||||
* adapter using the TypeScript resolver with
|
||||
* tsconfig path-alias support.
|
||||
* - `scope-resolver.ts` — `vueScopeResolver` wiring object.
|
||||
*
|
||||
* ## Known limitations
|
||||
*
|
||||
* 1. **Template expressions** — Full template AST parsing is not performed.
|
||||
* `vueScopeResolver.emitPostResolutionEdges` extracts five categories of
|
||||
* template-derived edges via lightweight regex, all emitted after standard
|
||||
* scope-resolution passes complete:
|
||||
* - PascalCase/kebab-case component references → `vue-template-component` `CALLS`
|
||||
* - `@event="handler"` on **native** elements → `vue-template-callback` `CALLS`
|
||||
* - `@event="handler"` on **component** elements → `vue-event: @<name>` `BINDS_EVENT_HANDLER`
|
||||
* - `emit(...)` / `this.$emit(...)` in script → `vue-emit: <name>` `EMITS_EVENT`
|
||||
* - `: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
|
||||
* a full template AST. Tracked in #1647.
|
||||
* 2. **Options API `this` resolution** — `this.X()` in Options API
|
||||
* components does not resolve through type-binding when the component
|
||||
* uses a plain object literal rather than a class. `fieldFallbackOnMethodLookup`
|
||||
* recovers common cases via field-name matching.
|
||||
* 3. **`<script setup>` + `<script>` dual-block** — When both blocks are
|
||||
* present, only `<script setup>` is processed (per `extractVueScript`
|
||||
* priority). The non-setup block is skipped.
|
||||
* 4. **JSX in `<template>`** — Vue's template compiler is not a
|
||||
* tree-sitter grammar; JSX-style bindings inside templates are not
|
||||
* processed by the scope-resolution pipeline.
|
||||
*/
|
||||
|
||||
export { emitVueScopeCaptures } from './captures.js';
|
||||
export { makeVueResolveImportTarget } from './import-target.js';
|
||||
export { vueScopeResolver } from './scope-resolver.js';
|
||||
324
gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts
Normal file
324
gitnexus/src/core/ingestion/languages/vue/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/**
|
||||
* Vue `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3, issue #940).
|
||||
*
|
||||
* ## Design rationale
|
||||
*
|
||||
* Vue SFCs compile down to TypeScript/JavaScript — the `<script>` /
|
||||
* `<script setup>` block is pure TS/JS, parsed with the TypeScript
|
||||
* grammar and captured by `emitVueScopeCaptures` (which delegates
|
||||
* to `emitTsScopeCaptures`). Because of this, nearly all hooks are
|
||||
* identical to the TypeScript resolver:
|
||||
*
|
||||
* - `mergeBindings` — TypeScript LEGB semantics apply in script blocks.
|
||||
* - `arityCompatibility` — same positional + rest rules.
|
||||
* - `buildMro` / `populateOwners` — shared with TypeScript.
|
||||
* - `isSuperReceiver` — `super(...)` / `super.foo` / `super[x]` pattern.
|
||||
* - `resolveImportTarget` — TypeScript resolver with `.vue` explicit-
|
||||
* extension support; tsconfig paths loaded via
|
||||
* `loadResolutionConfig`.
|
||||
*
|
||||
* ## Key differences from TypeScript
|
||||
*
|
||||
* - `language: SupportedLanguages.Vue` — routes the resolver to Vue
|
||||
* files only; TypeScript files use the TypeScript resolver.
|
||||
* - `languageProvider: vueProvider` — the Vue-specific language
|
||||
* provider supplies the right built-ins and export checker for
|
||||
* `<script setup>` (all top-level bindings implicitly exported).
|
||||
* - `importEdgeReason: 'vue-scope: import'` — distinct tag for
|
||||
* debugging / edge provenance.
|
||||
* - `allowGlobalFreeCallFallback: false` — Vue uses explicit imports;
|
||||
* workspace-wide unique-name fallback is unnecessary and would
|
||||
* produce spurious edges for Vue built-ins (ref, reactive, …).
|
||||
*
|
||||
* ## Options API / this-binding
|
||||
*
|
||||
* Options API (`defineComponent({ methods: { … } })`) stores methods
|
||||
* on the component instance, which tree-sitter sees as object property
|
||||
* values. `this.X()` inside a method resolves via the existing
|
||||
* `tsReceiverBinding` hook (inherited from TypeScript), which walks to
|
||||
* the enclosing Class scope. For Options API the enclosing "class" is
|
||||
* the `defineComponent({…})` object — not a true class — so `this`
|
||||
* calls may not resolve through the type-binding layer. `fieldFallbackOnMethodLookup`
|
||||
* is therefore set to `true` so the field-name fallback catches common
|
||||
* patterns even without an explicit type annotation.
|
||||
*
|
||||
* ## `<script setup>` macro calls
|
||||
*
|
||||
* `defineProps`, `defineEmits`, `defineExpose`, `withDefaults`, etc.
|
||||
* are compiler macros available as globals inside `<script setup>`.
|
||||
* They are listed in `vueProvider.builtInNames` and therefore treated
|
||||
* as resolved without requiring an import edge.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages, getLanguageFromFilename } 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 { extractVueTemplateEdgeData } from '../../vue-sfc-extractor.js';
|
||||
import { extractParsedFile } from '../../scope-extractor-bridge.js';
|
||||
|
||||
// Languages whose files may be pulled into the Vue scope-resolution pass
|
||||
// as import-closure context (`.vue` → `.ts` / `.js` cross-file resolution).
|
||||
const VUE_SCOPE_CONTEXT_LANGUAGES = new Set<SupportedLanguages>([
|
||||
SupportedLanguages.Vue,
|
||||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.JavaScript,
|
||||
]);
|
||||
|
||||
function isVueScopeContextLanguage(lang: SupportedLanguages | null): boolean {
|
||||
return lang !== null && VUE_SCOPE_CONTEXT_LANGUAGES.has(lang);
|
||||
}
|
||||
|
||||
const vueScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Vue,
|
||||
languageProvider: vueProvider,
|
||||
importEdgeReason: 'vue-scope: import',
|
||||
|
||||
resolveImportTarget: makeVueResolveImportTarget(),
|
||||
|
||||
// Vue projects universally use TypeScript — load tsconfig so path
|
||||
// aliases (`@/`, `~/`, `#/`) resolve through the standard branch.
|
||||
loadResolutionConfig: async (repoPath: string) => ({
|
||||
tsconfigPaths: await loadTsconfigPaths(repoPath),
|
||||
}),
|
||||
|
||||
// TypeScript LEGB semantics apply inside `<script>` / `<script setup>`.
|
||||
mergeBindings: (existing, incoming) => [...typescriptMergeBindings([...existing, ...incoming])],
|
||||
|
||||
// Adapter: typescriptArityCompatibility uses (def, callsite); contract is (callsite, def).
|
||||
arityCompatibility: (callsite, def) => typescriptArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
isSuperReceiver: (text) => /^super(\s*\(|\s*\.|\s*\[|\s*$)/.test(text.trim()),
|
||||
|
||||
// Options API `this.X()` calls may not resolve through the type-binding
|
||||
// layer (no formal class declaration), so enable the field-fallback
|
||||
// heuristic to catch them via declared field names.
|
||||
fieldFallbackOnMethodLookup: true,
|
||||
|
||||
// Return-type propagation mirrors TypeScript.
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
hoistTypeBindingsToModule: true,
|
||||
|
||||
// Vue uses explicit imports for all external symbols; no global free-
|
||||
// call fallback needed (would produce spurious edges for built-ins).
|
||||
allowGlobalFreeCallFallback: false,
|
||||
|
||||
/**
|
||||
* Expand the scope-resolution file universe for Vue by performing a
|
||||
* transitive closure over imports starting from the primary `.vue` files.
|
||||
*
|
||||
* Vue SFCs import TypeScript/JavaScript modules (`import { fn } from './api'`),
|
||||
* and those modules must be included in the Vue resolution pass for cross-file
|
||||
* IMPORTS/CALLS edges to resolve correctly. Without this expansion, only
|
||||
* `.vue` files would be processed and all TS/JS imports would remain
|
||||
* unresolved.
|
||||
*
|
||||
* Keeping this logic here (rather than hard-coding it in `phase.ts`) ensures
|
||||
* that shared pipeline code remains language-agnostic.
|
||||
*/
|
||||
collectScopeContextPaths({
|
||||
primaryFilePaths,
|
||||
preExtractedByPath,
|
||||
entryFileContents,
|
||||
allScannedPaths,
|
||||
resolutionConfig,
|
||||
}) {
|
||||
const resolveTargets = (targetRaw: string, fromFile: string): readonly string[] => {
|
||||
const resolved = vueScopeResolver.resolveImportTarget(
|
||||
targetRaw,
|
||||
fromFile,
|
||||
allScannedPaths,
|
||||
resolutionConfig,
|
||||
);
|
||||
if (resolved === null) return [];
|
||||
if (typeof resolved === 'string') return [resolved];
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const visited = new Set<string>(primaryFilePaths);
|
||||
const queue = [...primaryFilePaths];
|
||||
const fallbackParsed = new Map<string, ParsedFile>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) break;
|
||||
let parsed = preExtractedByPath.get(current) ?? fallbackParsed.get(current) ?? undefined;
|
||||
if (parsed === undefined) {
|
||||
const source = entryFileContents.get(current);
|
||||
if (source !== undefined) {
|
||||
parsed = extractParsedFile(vueProvider, source, current);
|
||||
if (parsed !== undefined) fallbackParsed.set(current, parsed);
|
||||
}
|
||||
}
|
||||
if (parsed === undefined) continue;
|
||||
|
||||
for (const parsedImport of parsed.parsedImports) {
|
||||
if (parsedImport.targetRaw.trim().length === 0) continue;
|
||||
for (const targetPath of resolveTargets(parsedImport.targetRaw, current)) {
|
||||
if (!allScannedPaths.has(targetPath)) continue;
|
||||
if (!isVueScopeContextLanguage(getLanguageFromFilename(targetPath))) continue;
|
||||
if (visited.has(targetPath)) continue;
|
||||
visited.add(targetPath);
|
||||
queue.push(targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
},
|
||||
|
||||
/**
|
||||
* Emit template-derived edges after standard scope-resolution passes.
|
||||
*
|
||||
* Six edge 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. **CALLS** (`vue-template-callback`) — reserved for future use
|
||||
* (see category 3 below).
|
||||
*
|
||||
* 3. **CALLS** (`vue-template-callback`)
|
||||
* `@event="handler"` on a **native** HTML element (`<button>`, `<input>`).
|
||||
* Source = the parent file (File node). Target = handler Function/Method.
|
||||
*
|
||||
* 4. **BINDS_EVENT_HANDLER** (`vue-event: @<eventName>`)
|
||||
* `@event="handler"` on a **component** element.
|
||||
* Source = the handler Function/Method node in the parent file.
|
||||
* Target = the child component's File node.
|
||||
*
|
||||
* 5. **EMITS_EVENT** (`vue-emit: <eventName>`)
|
||||
* `emit('eventName', …)` / `this.$emit('eventName', …)` in script block.
|
||||
* Source = the file's own File node (self-referential annotation).
|
||||
* Target = the same File node.
|
||||
* These "hanging" edges join with BINDS_EVENT_HANDLER via Cypher query
|
||||
* on the shared component File node to reveal handler/emitter pairs.
|
||||
*
|
||||
* 6. **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<string, string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract all template/script edge data in a single pass — avoids
|
||||
// re-running TEMPLATE_RE for each individual extractor call.
|
||||
const {
|
||||
templateComponents,
|
||||
nativeEventHandlers,
|
||||
componentEventBindings,
|
||||
scriptEmitCalls,
|
||||
templateAttributeBindings,
|
||||
} = extractVueTemplateEdgeData(content, { sourceKind: 'full-sfc' });
|
||||
|
||||
// 1 — Component-reference CALLS
|
||||
for (const componentName of templateComponents) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
// 3 — Native-element event-handler CALLS (@click="method" on <button> etc.)
|
||||
for (const handlerName of nativeEventHandlers) {
|
||||
const handlerNodeId = nodeLookup.get(simpleKey(parsedFile.filePath, handlerName));
|
||||
if (!handlerNodeId) continue;
|
||||
graph.addRelationship({
|
||||
id: generateId('CALLS', `${fileId}:@native:${handlerName}->${handlerNodeId}`),
|
||||
sourceId: fileId,
|
||||
targetId: handlerNodeId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.9,
|
||||
reason: 'vue-template-callback',
|
||||
});
|
||||
}
|
||||
|
||||
// 4 — BINDS_EVENT_HANDLER: component event bindings (@event="handler" on component elements)
|
||||
for (const { componentName, eventName, handlerName } of componentEventBindings) {
|
||||
const targetFile = importTargetByName.get(componentName);
|
||||
if (!targetFile) continue;
|
||||
const targetFileId = generateId('File', targetFile);
|
||||
if (!graph.getNode(targetFileId)) continue;
|
||||
|
||||
const handlerNodeId = nodeLookup.get(simpleKey(parsedFile.filePath, handlerName));
|
||||
if (!handlerNodeId) continue;
|
||||
|
||||
graph.addRelationship({
|
||||
id: generateId('BINDS_EVENT_HANDLER', `${handlerNodeId}:@${eventName}->${targetFileId}`),
|
||||
sourceId: handlerNodeId,
|
||||
targetId: targetFileId,
|
||||
type: 'BINDS_EVENT_HANDLER',
|
||||
confidence: 0.9,
|
||||
reason: `vue-event: @${eventName}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 5 — EMITS_EVENT: emit() / this.$emit() calls (self-referential annotation)
|
||||
for (const { eventName } of scriptEmitCalls) {
|
||||
graph.addRelationship({
|
||||
id: generateId('EMITS_EVENT', `${fileId}:emit:${eventName}`),
|
||||
sourceId: fileId,
|
||||
targetId: fileId,
|
||||
type: 'EMITS_EVENT',
|
||||
confidence: 0.9,
|
||||
reason: `vue-emit: ${eventName}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 6 — ACCESSES: bound-attribute references (:prop="varName")
|
||||
for (const varName of templateAttributeBindings) {
|
||||
const varNodeId = nodeLookup.get(simpleKey(parsedFile.filePath, varName));
|
||||
if (!varNodeId) continue;
|
||||
graph.addRelationship({
|
||||
id: generateId('ACCESSES', `${fileId}:bind:${varName}->${varNodeId}`),
|
||||
sourceId: fileId,
|
||||
targetId: varNodeId,
|
||||
type: 'ACCESSES',
|
||||
confidence: 0.8,
|
||||
reason: 'vue-template-attribute',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export { vueScopeResolver };
|
||||
|
|
@ -84,6 +84,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.Cobol,
|
||||
SupportedLanguages.Swift,
|
||||
SupportedLanguages.Dart,
|
||||
SupportedLanguages.Vue,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import type { LanguageProvider } from './language-provider.js';
|
|||
import { logger } from '../logger.js';
|
||||
/** Callback used to report scope-extraction warnings to the host (worker or direct). */
|
||||
export type ScopeBridgeWarn = (message: string) => void;
|
||||
export type ScopeCaptureSourceKind = 'full-file' | 'pre-extracted-script';
|
||||
|
||||
/**
|
||||
* Produce a `ParsedFile` for the given file, or `undefined` when the
|
||||
|
|
@ -43,11 +44,12 @@ export function extractParsedFile(
|
|||
filePath: string,
|
||||
onWarn?: ScopeBridgeWarn,
|
||||
cachedTree?: unknown,
|
||||
sourceKind: ScopeCaptureSourceKind = 'full-file',
|
||||
): ParsedFile | undefined {
|
||||
if (provider.emitScopeCaptures === undefined) return undefined;
|
||||
if (sourceText.trim().length === 0) return undefined;
|
||||
try {
|
||||
const captures = provider.emitScopeCaptures(sourceText, filePath, cachedTree);
|
||||
const captures = provider.emitScopeCaptures(sourceText, filePath, cachedTree, { sourceKind });
|
||||
return extractScope(captures, filePath, provider);
|
||||
} catch (err) {
|
||||
const message = `scope extraction failed for ${filePath}: ${
|
||||
|
|
|
|||
|
|
@ -915,6 +915,72 @@ export interface ScopeResolver {
|
|||
},
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Optional hook to expand the set of file paths handed to the scope-
|
||||
* resolution run for this language.
|
||||
*
|
||||
* Called once per language with:
|
||||
* - `primaryFilePaths` — files whose `getLanguageFromFilename` === this
|
||||
* resolver's `language` (e.g. all `.vue` files).
|
||||
* - `preExtractedByPath` — ParsedFile cache from the parse phase.
|
||||
* - `entryFileContents` — raw source text of the primary files.
|
||||
* - `allScannedPaths` — complete set of paths in the repository.
|
||||
* - `resolutionConfig` — language-specific config (tsconfig paths, …).
|
||||
*
|
||||
* Return value: the full set of paths to include in the scope-resolution
|
||||
* run. May be a superset of `primaryFilePaths`.
|
||||
*
|
||||
* Vue uses this hook to collect the transitive TS/JS import closure of
|
||||
* every `.vue` file so that cross-file imports (`import { fn } from './api'`)
|
||||
* resolve correctly within a single Vue scope-resolution pass.
|
||||
*
|
||||
* This hook keeps language-specific scope-context policy inside the language
|
||||
* module, preventing shared pipeline code (`phase.ts`) from naming individual
|
||||
* languages.
|
||||
*
|
||||
* Default: undefined (use only `primaryFilePaths`).
|
||||
*/
|
||||
readonly collectScopeContextPaths?: (options: {
|
||||
readonly primaryFilePaths: readonly string[];
|
||||
readonly preExtractedByPath: ReadonlyMap<string, import('gitnexus-shared').ParsedFile>;
|
||||
readonly entryFileContents: ReadonlyMap<string, string>;
|
||||
readonly allScannedPaths: ReadonlySet<string>;
|
||||
readonly resolutionConfig: unknown;
|
||||
}) => Set<string>;
|
||||
|
||||
/**
|
||||
* Optional post-resolution hook for emitting language-specific graph edges
|
||||
* that cannot be derived from scope captures or import resolution alone.
|
||||
*
|
||||
* Runs AFTER all standard edge-emission passes (receiver-bound CALLS,
|
||||
* free-call fallback, references-via-lookup, and import edges). Receives
|
||||
* the fully-resolved graph, all ParsedFiles, the node lookup, the finalized
|
||||
* scope indexes, and the raw file-content map.
|
||||
*
|
||||
* Vue uses this hook to emit:
|
||||
* - `CALLS` (`vue-template-component`) for PascalCase component elements
|
||||
* - `BINDS_EVENT_HANDLER` for `@event="handler"` on component elements
|
||||
* - `EMITS_EVENT` for `emit('eventName', …)` calls in script blocks
|
||||
* - `ACCESSES` (`vue-template-attribute`) for `:prop="var"` bindings
|
||||
*
|
||||
* Unlike `emitImplicitImportEdges` and `emitHeritageEdges` (which run
|
||||
* before MRO construction), this hook runs last, after the full graph is
|
||||
* populated, so it can safely query node existence and resolved import
|
||||
* targets via `indexes.imports`.
|
||||
*
|
||||
* Default: undefined (no supplementary edges needed).
|
||||
*/
|
||||
readonly emitPostResolutionEdges?: (
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly resolutionConfig?: unknown;
|
||||
},
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Optional post-resolution pass: emit CALLS edges for member-call sites
|
||||
* whose receiver cannot be typed by the scope chain (no `TypeRef`).
|
||||
|
|
|
|||
|
|
@ -146,8 +146,11 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
|
||||
// Pre-count files and languages for progress reporting. This avoids
|
||||
// a frozen progress bar during long scope-resolution runs (#1741).
|
||||
// Uses primary-language file counts only; languages that expand their
|
||||
// context via collectScopeContextPaths may process more files than shown.
|
||||
let totalScopeFiles = 0;
|
||||
let totalScopeLangs = 0;
|
||||
const allScannedPaths = new Set(scannedFiles.map((f) => f.path));
|
||||
for (const [lang] of SCOPE_RESOLVERS) {
|
||||
if (!isRegistryPrimary(lang)) continue;
|
||||
const count = scannedFiles.filter((f) => getLanguageFromFilename(f.path) === lang).length;
|
||||
|
|
@ -180,16 +183,9 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
// SCOPE_RESOLVERS.
|
||||
if (provider.languageProvider.parseStrategy === 'standalone') continue;
|
||||
|
||||
const langFiles = scannedFiles.filter((f) => getLanguageFromFilename(f.path) === lang);
|
||||
if (langFiles.length === 0) continue;
|
||||
|
||||
const filePaths = langFiles.map((f) => f.path);
|
||||
const contents = await readFileContents(ctx.repoPath, filePaths);
|
||||
const files: { path: string; content: string }[] = [];
|
||||
for (const fp of filePaths) {
|
||||
const content = contents.get(fp);
|
||||
if (content !== undefined) files.push({ path: fp, content });
|
||||
}
|
||||
const primaryLangFiles = scannedFiles.filter((f) => getLanguageFromFilename(f.path) === lang);
|
||||
if (primaryLangFiles.length === 0) continue;
|
||||
const primaryFilePaths = primaryLangFiles.map((f) => f.path);
|
||||
|
||||
// Load per-language import-resolution config (tsconfig paths,
|
||||
// composer.json autoload, go.mod, ...). One I/O round trip per
|
||||
|
|
@ -200,6 +196,40 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
? await provider.loadResolutionConfig(ctx.repoPath)
|
||||
: undefined;
|
||||
|
||||
// Some languages (e.g. Vue) expand their file universe beyond the
|
||||
// primary-language files via the `collectScopeContextPaths` hook.
|
||||
// The hook receives raw source contents of the primary files so it
|
||||
// can trace import closures without a second tree-sitter parse.
|
||||
//
|
||||
// To avoid reading primary files twice (once for the hook, once for
|
||||
// the resolution pass), we read them upfront and merge with the
|
||||
// extra context paths the hook may add.
|
||||
let scopeFilePaths: Set<string>;
|
||||
let contents: Map<string, string>;
|
||||
if (provider.collectScopeContextPaths !== undefined) {
|
||||
const entryFileContents = await readFileContents(ctx.repoPath, primaryFilePaths);
|
||||
scopeFilePaths = provider.collectScopeContextPaths({
|
||||
primaryFilePaths,
|
||||
preExtractedByPath,
|
||||
entryFileContents,
|
||||
allScannedPaths,
|
||||
resolutionConfig,
|
||||
});
|
||||
// Read only the extra context files (TS/JS etc.) not already loaded.
|
||||
const extraPaths = [...scopeFilePaths].filter((p) => !entryFileContents.has(p));
|
||||
const extraContents = await readFileContents(ctx.repoPath, extraPaths);
|
||||
contents = new Map([...entryFileContents, ...extraContents]);
|
||||
} else {
|
||||
scopeFilePaths = new Set(primaryFilePaths);
|
||||
contents = await readFileContents(ctx.repoPath, primaryFilePaths);
|
||||
}
|
||||
const filePaths = [...scopeFilePaths];
|
||||
const files: { path: string; content: string }[] = [];
|
||||
for (const fp of filePaths) {
|
||||
const content = contents.get(fp);
|
||||
if (content !== undefined) files.push({ path: fp, content });
|
||||
}
|
||||
|
||||
const langFileCount = files.length;
|
||||
const langLabel = lang.charAt(0).toUpperCase() + lang.slice(1);
|
||||
currentLangIdx++;
|
||||
|
|
@ -279,6 +309,10 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
// to reduce memory pressure. For large codebases (16K+ PHP files),
|
||||
// holding all source code simultaneously with scope trees causes OOM.
|
||||
// See: https://github.com/abhigyanpatwari/GitNexus/issues/1741
|
||||
//
|
||||
// Use `filePaths` (not `primaryFilePaths`) so that any context files
|
||||
// added by `collectScopeContextPaths` (e.g. TS/JS files pulled in for
|
||||
// Vue cross-file resolution) are also evicted and not held until GC.
|
||||
files.length = 0;
|
||||
contents.clear();
|
||||
for (const fp of filePaths) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { rubyScopeResolver } from '../../languages/ruby/scope-resolver.js';
|
|||
import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js';
|
||||
import { swiftScopeResolver } from '../../languages/swift/scope-resolver.js';
|
||||
import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
|
||||
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
|
||||
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
|
||||
|
|
@ -50,4 +51,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.Cobol, cobolScopeResolver],
|
||||
[SupportedLanguages.Swift, swiftScopeResolver],
|
||||
[SupportedLanguages.Dart, dartScopeResolver],
|
||||
[SupportedLanguages.Vue, vueScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -592,6 +592,16 @@ export function runScopeResolution(
|
|||
provider.importEdgeReason,
|
||||
);
|
||||
|
||||
// Language-specific supplementary edges (e.g. Vue template-derived
|
||||
// BINDS_EVENT_HANDLER / EMITS_EVENT / CALLS / ACCESSES edges).
|
||||
// Runs last so the full graph — including import edges — is visible.
|
||||
if (provider.emitPostResolutionEdges !== undefined) {
|
||||
provider.emitPostResolutionEdges(graph, parsedFiles, postHeritageNodeLookup, indexes, {
|
||||
fileContents: getFileContents(),
|
||||
resolutionConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (PROF) {
|
||||
const tEnd = process.hrtime.bigint();
|
||||
const ns = (a: bigint, b: bigint): number => Number(b - a) / 1_000_000;
|
||||
|
|
|
|||
|
|
@ -42,10 +42,22 @@ interface ScriptBlock {
|
|||
// attribute axes; this expression closes both at once.
|
||||
const SCRIPT_RE = /<script(\s[^>]*)?>([^]*?)<\/script[^>]*>/gi;
|
||||
const TEMPLATE_COMPONENT_RE = /<([A-Z][A-Za-z0-9]+)/g;
|
||||
const TEMPLATE_KEBAB_COMPONENT_RE = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)\b/g;
|
||||
// Greedy: matches from the first <template> to the *last* </template>.
|
||||
// This is intentional — nested <template v-slot:...> tags are valid Vue
|
||||
// syntax and we want the entire outermost template body.
|
||||
const TEMPLATE_RE = /<template(\s[^>]*)?>([^]*)<\/template>/;
|
||||
const VUE_BUILTIN_KEBAB_TAGS = new Set<string>([
|
||||
'router-view',
|
||||
'router-link',
|
||||
'transition',
|
||||
'transition-group',
|
||||
'keep-alive',
|
||||
'teleport',
|
||||
'suspense',
|
||||
'component',
|
||||
'slot',
|
||||
]);
|
||||
|
||||
function countNewlines(text: string): number {
|
||||
let count = 0;
|
||||
|
|
@ -55,6 +67,160 @@ function countNewlines(text: string): number {
|
|||
return count;
|
||||
}
|
||||
|
||||
function kebabToPascal(name: string): string {
|
||||
return name
|
||||
.split('-')
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isBuiltinKebabTag(tagName: string): boolean {
|
||||
return VUE_BUILTIN_KEBAB_TAGS.has(tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bare `emit('event')` / `$emit('event')` calls from script text.
|
||||
*
|
||||
* Uses a lightweight lexer state-machine (code vs comments/strings), so:
|
||||
* - ignores `emit(...)` in comments and string literals
|
||||
* - ignores property calls like `socket.emit(...)` / `this.$emit(...)`
|
||||
* - captures only literal-string event names
|
||||
*/
|
||||
function collectBareEmitEventNames(input: string): string[] {
|
||||
enum Mode {
|
||||
Code,
|
||||
SingleQuote,
|
||||
DoubleQuote,
|
||||
Template,
|
||||
LineComment,
|
||||
BlockComment,
|
||||
}
|
||||
|
||||
const events: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let mode = Mode.Code;
|
||||
|
||||
const isIdentChar = (c: string): boolean => /[A-Za-z0-9_$]/.test(c);
|
||||
const skipSpaces = (idx: number): number => {
|
||||
let i = idx;
|
||||
while (i < input.length && /\s/.test(input[i])) i++;
|
||||
return i;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return true if the token immediately before the `.` at `dotIdx` is the
|
||||
* keyword `this` and is not itself a property access (e.g. not `foo.this`).
|
||||
* Used to allow `this.$emit(...)` while blocking `socket.emit(...)`.
|
||||
*/
|
||||
const lookbackIsThis = (dotIdx: number): boolean => {
|
||||
let j = dotIdx - 1; // step past the '.'
|
||||
while (j >= 0 && /\s/.test(input[j])) j--;
|
||||
if (j < 3) return false;
|
||||
if (input.slice(j - 3, j + 1) !== 'this') return false;
|
||||
// Ensure 'this' is not itself a property target (e.g. foo.this)
|
||||
const prevOfThis = j >= 4 ? input[j - 4] : '';
|
||||
return !(prevOfThis.length > 0 && (isIdentChar(prevOfThis) || prevOfThis === '.'));
|
||||
};
|
||||
|
||||
const tryConsumeEmitCall = (idx: number): number => {
|
||||
const hasDollar = input[idx] === '$';
|
||||
const name = hasDollar ? '$emit' : 'emit';
|
||||
if (!input.startsWith(name, idx)) return idx;
|
||||
const prev = idx > 0 ? input[idx - 1] : '';
|
||||
if (prev.length > 0 && isIdentChar(prev)) return idx;
|
||||
if (prev === '.') {
|
||||
// Allow `this.$emit(...)` / `this.emit(...)` but block `socket.emit(...)`.
|
||||
if (!lookbackIsThis(idx - 1)) return idx;
|
||||
}
|
||||
const afterName = idx + name.length;
|
||||
const next = afterName < input.length ? input[afterName] : '';
|
||||
if (next.length > 0 && isIdentChar(next)) return idx;
|
||||
|
||||
let i = skipSpaces(afterName);
|
||||
if (input[i] !== '(') return idx;
|
||||
i = skipSpaces(i + 1);
|
||||
const quote = input[i];
|
||||
if (quote !== "'" && quote !== '"') return idx;
|
||||
|
||||
let eventName = '';
|
||||
i++;
|
||||
while (i < input.length) {
|
||||
const ch = input[i];
|
||||
if (ch === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) break;
|
||||
eventName += ch;
|
||||
i++;
|
||||
}
|
||||
if (i >= input.length) return idx;
|
||||
// Allow simple names ("save"), hyphenated names ("user-loaded"), and
|
||||
// update modifier patterns ("update:modelValue", "update:model-value").
|
||||
if (/^[A-Za-z$_][A-Za-z0-9:_$-]*$/.test(eventName) && !seen.has(eventName)) {
|
||||
seen.add(eventName);
|
||||
events.push(eventName);
|
||||
}
|
||||
return i;
|
||||
};
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const ch = input[i];
|
||||
const next = i + 1 < input.length ? input[i + 1] : '';
|
||||
|
||||
if (mode === Mode.Code) {
|
||||
const consumedAt = tryConsumeEmitCall(i);
|
||||
if (consumedAt !== i) {
|
||||
i = consumedAt;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
mode = ch === "'" ? Mode.SingleQuote : ch === '"' ? Mode.DoubleQuote : Mode.Template;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
mode = Mode.LineComment;
|
||||
i++;
|
||||
} else if (ch === '/' && next === '*') {
|
||||
mode = Mode.BlockComment;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode === Mode.LineComment) {
|
||||
if (ch === '\n') {
|
||||
mode = Mode.Code;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode === Mode.BlockComment) {
|
||||
if (ch === '*' && next === '/') {
|
||||
i++;
|
||||
mode = Mode.Code;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(mode === Mode.SingleQuote && ch === "'") ||
|
||||
(mode === Mode.DoubleQuote && ch === '"') ||
|
||||
(mode === Mode.Template && ch === '`')
|
||||
) {
|
||||
mode = Mode.Code;
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function parseScriptBlock(
|
||||
attrs: string | undefined,
|
||||
content: string,
|
||||
|
|
@ -138,5 +304,318 @@ export function extractTemplateComponents(vueContent: string): string[] {
|
|||
components.add(componentMatch[1]);
|
||||
}
|
||||
|
||||
TEMPLATE_KEBAB_COMPONENT_RE.lastIndex = 0;
|
||||
while ((componentMatch = TEMPLATE_KEBAB_COMPONENT_RE.exec(templateContent)) !== null) {
|
||||
if (isBuiltinKebabTag(componentMatch[1])) continue;
|
||||
components.add(kebabToPascal(componentMatch[1]));
|
||||
}
|
||||
|
||||
return [...components];
|
||||
}
|
||||
|
||||
// ── Per-element event binding extraction ──────────────────────────────────
|
||||
//
|
||||
// Three sibling regexes capture opening tags distinguished by PascalCase
|
||||
// (Vue components), kebab-case (Vue components in kebab form), and simple
|
||||
// lowercase (native HTML elements). All stop their attribute-block capture
|
||||
// at the first `>` with a bounded span of at most 512 characters to avoid
|
||||
// pathological backtracking on large template files (ReDoS mitigation).
|
||||
// Multi-line tags whose attribute block contains a literal `>` are documented
|
||||
// as a known limitation (#1647).
|
||||
//
|
||||
// NATIVE_TAG_RE uses `(?![A-Za-z0-9-])` to prevent matching the `post` prefix
|
||||
// of a kebab-case component tag like `<post-list>` — such tags are handled by
|
||||
// KEBAB_COMPONENT_TAG_RE, not NATIVE_TAG_RE.
|
||||
const COMPONENT_TAG_RE = /<([A-Z][A-Za-z0-9]+)([^>]{0,512}?)(?:\/>|>)/g;
|
||||
const KEBAB_COMPONENT_TAG_RE = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)([^>]{0,512}?)(?:\/>|>)/g;
|
||||
const NATIVE_TAG_RE = /<([a-z][a-z0-9]*)(?![A-Za-z0-9-])([^>]{0,512}?)(?:\/>|>)/g;
|
||||
|
||||
// Within any tag's attribute block: matches Vue event bindings.
|
||||
// @action="handleAction"
|
||||
// @keyup.enter="submit"
|
||||
// @user-loaded="onLoaded" — hyphenated event names
|
||||
// @update:model-value="onChange" — update modifier with colon
|
||||
// v-on:click="onClick"
|
||||
const TAG_EVENT_RE = /(?:@|v-on:)([\w:.-]+)\s*=\s*["']([A-Za-z_$][A-Za-z0-9_$]*)["']/g;
|
||||
|
||||
// ── Script emit() call extraction ─────────────────────────────────────────
|
||||
|
||||
// Matches simple variable references in Vue bound-attribute values.
|
||||
// Captures only bare identifiers — not member-access (":key=\"post.id\""),
|
||||
// literals (":id=\"1\""), or expressions (":val=\"a + b\"").
|
||||
//
|
||||
// :userId="currentUserId" → "currentUserId"
|
||||
// :posts="allPosts" → "allPosts"
|
||||
// v-bind:disabled="isLoading" → "isLoading"
|
||||
// :key="post.id" — skipped (member access)
|
||||
// :id="1" — skipped (literal)
|
||||
const BOUND_ATTR_RE = /(?::[\w-]+|v-bind:[\w-]+)\s*=\s*["']([A-Za-z_$][A-Za-z0-9_$]*)["']/g;
|
||||
|
||||
export interface ComponentEventBinding {
|
||||
/** PascalCase name of the child component element (e.g. `"PostList"`). */
|
||||
componentName: string;
|
||||
/** Vue event name without the `@` prefix (e.g. `"select"`, `"keyup.enter"`). */
|
||||
eventName: string;
|
||||
/** Bare identifier of the parent handler function (e.g. `"onPostSelected"`). */
|
||||
handlerName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Vue component event bindings from a `<template>` block.
|
||||
*
|
||||
* Scans PascalCase component elements (e.g. `<PostList>`, `<UserCard>`) and
|
||||
* returns each `@event="handler"` binding found in the element's attribute
|
||||
* block. Native HTML element event handlers (`@click` on `<button>`, etc.)
|
||||
* are intentionally excluded — only component-to-component event bindings
|
||||
* that go through Vue's `emit()` / `defineEmits` system are included.
|
||||
*
|
||||
* **Limitation:** component tags whose attribute block spans multiple lines
|
||||
* and contains a `>` inside an attribute value are not captured (the regex
|
||||
* stops at the first `>`). Full template AST parsing would be required for
|
||||
* those edge cases (tracked in #1647).
|
||||
*/
|
||||
export function extractComponentEventBindings(vueContent: string): ComponentEventBinding[] {
|
||||
const templateMatch = TEMPLATE_RE.exec(vueContent);
|
||||
if (!templateMatch) return [];
|
||||
|
||||
const templateContent = templateMatch[2];
|
||||
const bindings: ComponentEventBinding[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
COMPONENT_TAG_RE.lastIndex = 0;
|
||||
let tagMatch: RegExpExecArray | null;
|
||||
while ((tagMatch = COMPONENT_TAG_RE.exec(templateContent)) !== null) {
|
||||
const componentName = tagMatch[1];
|
||||
const attrs = tagMatch[2];
|
||||
|
||||
TAG_EVENT_RE.lastIndex = 0;
|
||||
let eventMatch: RegExpExecArray | null;
|
||||
while ((eventMatch = TAG_EVENT_RE.exec(attrs)) !== null) {
|
||||
const eventName = eventMatch[1];
|
||||
const handlerName = eventMatch[2];
|
||||
const key = `${componentName}::${eventName}::${handlerName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
bindings.push({ componentName, eventName, handlerName });
|
||||
}
|
||||
}
|
||||
|
||||
KEBAB_COMPONENT_TAG_RE.lastIndex = 0;
|
||||
while ((tagMatch = KEBAB_COMPONENT_TAG_RE.exec(templateContent)) !== null) {
|
||||
if (isBuiltinKebabTag(tagMatch[1])) continue;
|
||||
const componentName = kebabToPascal(tagMatch[1]);
|
||||
const attrs = tagMatch[2];
|
||||
|
||||
TAG_EVENT_RE.lastIndex = 0;
|
||||
let eventMatch: RegExpExecArray | null;
|
||||
while ((eventMatch = TAG_EVENT_RE.exec(attrs)) !== null) {
|
||||
const eventName = eventMatch[1];
|
||||
const handlerName = eventMatch[2];
|
||||
const key = `${componentName}::${eventName}::${handlerName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
bindings.push({ componentName, eventName, handlerName });
|
||||
}
|
||||
}
|
||||
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract event handler names bound to native HTML elements in the template.
|
||||
*
|
||||
* Only processes lowercase-named elements (`<button>`, `<input>`, `<div>`,
|
||||
* etc.) — PascalCase component elements are handled by
|
||||
* `extractComponentEventBindings`. Returns bare handler identifiers only;
|
||||
* inline expressions with arguments or arrow functions are excluded.
|
||||
*
|
||||
* These handlers represent direct DOM-event→function relationships and
|
||||
* are emitted as `CALLS` edges (not `BINDS_EVENT_HANDLER`), because native
|
||||
* events are synchronous browser callbacks, not Vue's component-event system.
|
||||
*/
|
||||
export function extractNativeElementEventHandlers(vueContent: string): string[] {
|
||||
const templateMatch = TEMPLATE_RE.exec(vueContent);
|
||||
if (!templateMatch) return [];
|
||||
|
||||
const templateContent = templateMatch[2];
|
||||
const handlers: string[] = [];
|
||||
|
||||
NATIVE_TAG_RE.lastIndex = 0;
|
||||
let tagMatch: RegExpExecArray | null;
|
||||
while ((tagMatch = NATIVE_TAG_RE.exec(templateContent)) !== null) {
|
||||
const attrs = tagMatch[2];
|
||||
|
||||
TAG_EVENT_RE.lastIndex = 0;
|
||||
let eventMatch: RegExpExecArray | null;
|
||||
while ((eventMatch = TAG_EVENT_RE.exec(attrs)) !== null) {
|
||||
handlers.push(eventMatch[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return handlers;
|
||||
}
|
||||
|
||||
export interface ScriptEmitCall {
|
||||
/** Vue event name passed to `emit()` (e.g. `"action"`, `"update"`). */
|
||||
eventName: string;
|
||||
}
|
||||
|
||||
export interface ExtractScriptEmitCallsOptions {
|
||||
/**
|
||||
* How to interpret the input text.
|
||||
* - `full-sfc` (default): input is a full `.vue` SFC string.
|
||||
* - `pre-extracted-script`: input is already the bare script text.
|
||||
*/
|
||||
sourceKind?: 'full-sfc' | 'pre-extracted-script';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `emit('eventName', ...)` calls from a Vue SFC's `<script>` block.
|
||||
*
|
||||
* Scans the raw SFC source (full `.vue` file), extracts the script content,
|
||||
* then finds bare `emit('...')` calls. Only captures literal string event
|
||||
* names — dynamic expressions (`emit(eventName)`) are excluded.
|
||||
*
|
||||
* Returns deduplicated emit declarations.
|
||||
*/
|
||||
export function extractScriptEmitCalls(
|
||||
vueContent: string,
|
||||
options: ExtractScriptEmitCallsOptions = {},
|
||||
): ScriptEmitCall[] {
|
||||
const sourceKind = options.sourceKind ?? 'full-sfc';
|
||||
const scriptText =
|
||||
sourceKind === 'pre-extracted-script'
|
||||
? vueContent
|
||||
: (extractVueScript(vueContent)?.scriptContent ?? null);
|
||||
if (!scriptText) return [];
|
||||
return collectBareEmitEventNames(scriptText).map((eventName) => ({ eventName }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract variable identifiers from Vue template bound-attribute values.
|
||||
*
|
||||
* Covers `:prop="varName"` and `v-bind:prop="varName"` patterns where
|
||||
* the value is a single plain identifier. Member-access expressions
|
||||
* (`:key="post.id"`) and literals are excluded by design.
|
||||
*
|
||||
* Returns deduplicated identifier names.
|
||||
*/
|
||||
export function extractTemplateAttributeBindings(vueContent: string): string[] {
|
||||
const templateMatch = TEMPLATE_RE.exec(vueContent);
|
||||
if (!templateMatch) return [];
|
||||
|
||||
const templateContent = templateMatch[2];
|
||||
const vars = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
BOUND_ATTR_RE.lastIndex = 0;
|
||||
while ((match = BOUND_ATTR_RE.exec(templateContent)) !== null) {
|
||||
vars.add(match[1]);
|
||||
}
|
||||
|
||||
return [...vars];
|
||||
}
|
||||
|
||||
export interface VueTemplateEdgeData {
|
||||
/** PascalCase component names referenced in the template. */
|
||||
readonly templateComponents: readonly string[];
|
||||
/** Handler names on native elements (@click="fn"). */
|
||||
readonly nativeEventHandlers: readonly string[];
|
||||
/** Component event bindings (@event="handler" on component elements). */
|
||||
readonly componentEventBindings: readonly ComponentEventBinding[];
|
||||
/** Event names from emit() / this.$emit() calls in the script block. */
|
||||
readonly scriptEmitCalls: readonly ScriptEmitCall[];
|
||||
/** Bound attribute variable names (:prop="varName"). */
|
||||
readonly templateAttributeBindings: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all template-derived edge data from a Vue SFC in a single pass.
|
||||
*
|
||||
* Parses the `<template>` block once and the `<script>` block once, then
|
||||
* runs all five extractors on the pre-parsed content rather than repeating
|
||||
* the regex on every individual call. Used by `emitPostResolutionEdges`
|
||||
* to avoid multiple full-file scans per `.vue` file.
|
||||
*/
|
||||
export function extractVueTemplateEdgeData(
|
||||
vueContent: string,
|
||||
options: ExtractScriptEmitCallsOptions = {},
|
||||
): VueTemplateEdgeData {
|
||||
// Extract template content once.
|
||||
const templateMatch = TEMPLATE_RE.exec(vueContent);
|
||||
const tmpl = templateMatch ? templateMatch[2] : '';
|
||||
|
||||
// Template components (PascalCase + kebab-case).
|
||||
const componentSet = new Set<string>();
|
||||
if (tmpl) {
|
||||
TEMPLATE_COMPONENT_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TEMPLATE_COMPONENT_RE.exec(tmpl)) !== null) componentSet.add(m[1]);
|
||||
TEMPLATE_KEBAB_COMPONENT_RE.lastIndex = 0;
|
||||
while ((m = TEMPLATE_KEBAB_COMPONENT_RE.exec(tmpl)) !== null) {
|
||||
if (!isBuiltinKebabTag(m[1])) componentSet.add(kebabToPascal(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
// Native element event handlers.
|
||||
const nativeHandlers: string[] = [];
|
||||
if (tmpl) {
|
||||
NATIVE_TAG_RE.lastIndex = 0;
|
||||
let tagM: RegExpExecArray | null;
|
||||
while ((tagM = NATIVE_TAG_RE.exec(tmpl)) !== null) {
|
||||
TAG_EVENT_RE.lastIndex = 0;
|
||||
let evM: RegExpExecArray | null;
|
||||
while ((evM = TAG_EVENT_RE.exec(tagM[2])) !== null) nativeHandlers.push(evM[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Component event bindings.
|
||||
const componentBindings: ComponentEventBinding[] = [];
|
||||
const bindingSeen = new Set<string>();
|
||||
const processComponentAttrs = (componentName: string, attrs: string): void => {
|
||||
TAG_EVENT_RE.lastIndex = 0;
|
||||
let evM: RegExpExecArray | null;
|
||||
while ((evM = TAG_EVENT_RE.exec(attrs)) !== null) {
|
||||
const key = `${componentName}::${evM[1]}::${evM[2]}`;
|
||||
if (!bindingSeen.has(key)) {
|
||||
bindingSeen.add(key);
|
||||
componentBindings.push({ componentName, eventName: evM[1], handlerName: evM[2] });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (tmpl) {
|
||||
COMPONENT_TAG_RE.lastIndex = 0;
|
||||
let tagM: RegExpExecArray | null;
|
||||
while ((tagM = COMPONENT_TAG_RE.exec(tmpl)) !== null) processComponentAttrs(tagM[1], tagM[2]);
|
||||
KEBAB_COMPONENT_TAG_RE.lastIndex = 0;
|
||||
while ((tagM = KEBAB_COMPONENT_TAG_RE.exec(tmpl)) !== null) {
|
||||
if (!isBuiltinKebabTag(tagM[1])) processComponentAttrs(kebabToPascal(tagM[1]), tagM[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Script emit() calls.
|
||||
const sourceKind = options.sourceKind ?? 'full-sfc';
|
||||
const scriptText =
|
||||
sourceKind === 'pre-extracted-script'
|
||||
? vueContent
|
||||
: (extractVueScript(vueContent)?.scriptContent ?? null);
|
||||
const scriptEmitCalls: ScriptEmitCall[] = scriptText
|
||||
? collectBareEmitEventNames(scriptText).map((eventName) => ({ eventName }))
|
||||
: [];
|
||||
|
||||
// Bound attribute bindings.
|
||||
const attrVars = new Set<string>();
|
||||
if (tmpl) {
|
||||
BOUND_ATTR_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = BOUND_ATTR_RE.exec(tmpl)) !== null) attrVars.add(m[1]);
|
||||
}
|
||||
|
||||
return {
|
||||
templateComponents: [...componentSet],
|
||||
nativeEventHandlers: nativeHandlers,
|
||||
componentEventBindings: componentBindings,
|
||||
scriptEmitCalls,
|
||||
templateAttributeBindings: [...attrVars],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ import {
|
|||
import { extractTemplateArguments, templateArgumentsIdTag } from '../utils/template-arguments.js';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { extractParsedFile } from '../scope-extractor-bridge.js';
|
||||
import { extractParsedFile, type ScopeCaptureSourceKind } from '../scope-extractor-bridge.js';
|
||||
import { extractLaravelRoutes, type ExtractedRoute } from '../route-extractors/laravel.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
|
|
@ -1111,12 +1111,14 @@ const processFileGroup = (
|
|||
|
||||
// Vue SFC preprocessing: extract <script> block content
|
||||
let parseContent = file.content;
|
||||
let scopeSourceKind: ScopeCaptureSourceKind = 'full-file';
|
||||
let lineOffset = 0;
|
||||
let isVueSetup = false;
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const extracted = extractVueScript(file.content);
|
||||
if (!extracted) continue; // skip .vue files with no script block
|
||||
parseContent = extracted.scriptContent;
|
||||
scopeSourceKind = 'pre-extracted-script';
|
||||
lineOffset = extracted.lineOffset;
|
||||
isVueSetup = extracted.isSetup;
|
||||
}
|
||||
|
|
@ -1174,6 +1176,7 @@ const processFileGroup = (
|
|||
}
|
||||
},
|
||||
tree,
|
||||
scopeSourceKind,
|
||||
);
|
||||
if (parsedFile !== undefined) result.parsedFiles.push(parsedFile);
|
||||
|
||||
|
|
|
|||
20
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/App.vue
vendored
Normal file
20
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/App.vue
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<UserProfile :userId="currentUserId" />
|
||||
<PostList :posts="allPosts" @select="onPostSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { Post } from './types';
|
||||
import UserProfile from './UserProfile.vue';
|
||||
import PostList from './PostList.vue';
|
||||
|
||||
const currentUserId = ref(1);
|
||||
const allPosts = ref<Post[]>([]);
|
||||
|
||||
function onPostSelected(post: Post) {
|
||||
console.log('selected', post.id);
|
||||
}
|
||||
</script>
|
||||
33
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/PostList.vue
vendored
Normal file
33
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/PostList.vue
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<template>
|
||||
<div class="post-list">
|
||||
<div v-for="post in posts" :key="post.id" class="post-item">
|
||||
<h3>{{ post.title }}</h3>
|
||||
<button @click="selectPost(post)">View</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { Post } from './types';
|
||||
import { formatPost } from './types';
|
||||
|
||||
const props = defineProps<{
|
||||
posts: Post[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [post: Post];
|
||||
}>();
|
||||
|
||||
const selectedPost = ref<Post | null>(null);
|
||||
|
||||
function selectPost(post: Post) {
|
||||
selectedPost.value = post;
|
||||
emit('select', post);
|
||||
}
|
||||
|
||||
function getLabel(post: Post): string {
|
||||
return formatPost(post);
|
||||
}
|
||||
</script>
|
||||
43
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/UserProfile.vue
vendored
Normal file
43
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/UserProfile.vue
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div class="user-profile">
|
||||
<h1>{{ displayName }}</h1>
|
||||
<p>{{ user?.email }}</p>
|
||||
<button @click="handleSave">Save</button>
|
||||
<ul>
|
||||
<li v-for="post in posts" :key="post.id">{{ formatPost(post) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import type { User, Post } from './types';
|
||||
import { formatUser, formatPost } from './types';
|
||||
import { fetchUser, fetchPosts, saveUser } from './api';
|
||||
|
||||
const props = defineProps<{
|
||||
userId: number;
|
||||
}>();
|
||||
|
||||
const user = ref<User | null>(null);
|
||||
const posts = ref<Post[]>([]);
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (user.value === null) return 'Loading...';
|
||||
return formatUser(user.value);
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
user.value = await fetchUser(props.userId);
|
||||
posts.value = await fetchPosts(props.userId);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (user.value === null) return;
|
||||
user.value = await saveUser(user.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
18
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/api.ts
vendored
Normal file
18
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/api.ts
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { User, Post } from './types';
|
||||
|
||||
export async function fetchUser(id: number): Promise<User> {
|
||||
const response = await fetch(`/api/users/${id}`);
|
||||
return response.json() as Promise<User>;
|
||||
}
|
||||
|
||||
export async function fetchPosts(userId: number): Promise<Post[]> {
|
||||
const response = await fetch(`/api/users/${userId}/posts`);
|
||||
return response.json() as Promise<Post[]>;
|
||||
}
|
||||
|
||||
export function saveUser(user: User): Promise<User> {
|
||||
return fetch('/api/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(user),
|
||||
}).then((r) => r.json() as Promise<User>);
|
||||
}
|
||||
19
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/types.ts
vendored
Normal file
19
gitnexus/test/fixtures/vue-scope/vue-composition-api/src/types.ts
vendored
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
20
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/App.vue
vendored
Normal file
20
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/App.vue
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<UserCard :userId="1" @loaded="onUserLoaded" />
|
||||
<PostCard :postId="42" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserList } from './composables/useUser';
|
||||
import { UserModel } from './models';
|
||||
import UserCard from './components/UserCard.vue';
|
||||
import PostCard from './components/PostCard.vue';
|
||||
|
||||
const { users, addUser } = useUserList();
|
||||
|
||||
function onUserLoaded(userId: number) {
|
||||
const u = new UserModel(userId, 'Loaded', 'user');
|
||||
addUser(u);
|
||||
}
|
||||
</script>
|
||||
22
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/PostCard.vue
vendored
Normal file
22
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/PostCard.vue
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div class="post-card">
|
||||
<h3>{{ title }}</h3>
|
||||
<p>{{ summary }}</p>
|
||||
<small>Words: {{ wordCount }}</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { usePost } from '../composables/usePost';
|
||||
|
||||
const props = defineProps<{ postId: number }>();
|
||||
|
||||
const { post, loadPost, getSummary } = usePost();
|
||||
|
||||
loadPost(props.postId);
|
||||
|
||||
const title = computed(() => post.value?.title ?? '');
|
||||
const summary = getSummary();
|
||||
const wordCount = computed(() => post.value?.wordCount() ?? 0);
|
||||
</script>
|
||||
23
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/UserCard.vue
vendored
Normal file
23
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/UserCard.vue
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="user-card">
|
||||
<h2>{{ displayName }}</h2>
|
||||
<span v-if="isAdmin" class="badge">Admin</span>
|
||||
<button @click="reload">Reload</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUser } from '../composables/useUser';
|
||||
|
||||
const props = defineProps<{ userId: number }>();
|
||||
const emit = defineEmits<{ loaded: [userId: number] }>();
|
||||
|
||||
const { user, isAdmin, loadUser, getDisplayName } = useUser(props.userId);
|
||||
|
||||
const displayName = getDisplayName();
|
||||
|
||||
async function reload() {
|
||||
await loadUser(props.userId);
|
||||
emit('loaded', props.userId);
|
||||
}
|
||||
</script>
|
||||
18
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/usePost.ts
vendored
Normal file
18
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/usePost.ts
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { ref } from 'vue';
|
||||
import { PostModel } from '../models';
|
||||
|
||||
export function usePost() {
|
||||
const post = ref<PostModel | null>(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 };
|
||||
}
|
||||
34
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/useUser.ts
vendored
Normal file
34
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/useUser.ts
vendored
Normal file
|
|
@ -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<UserModel | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const isAdmin = computed(() => user.value?.isAdmin() ?? false);
|
||||
|
||||
async function loadUser(id: number): Promise<UserModel> {
|
||||
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<UserModel[]>; addUser: (u: UserModel) => void } {
|
||||
const users = ref<UserModel[]>([]);
|
||||
|
||||
function addUser(u: UserModel) {
|
||||
users.value.push(u);
|
||||
}
|
||||
|
||||
return { users, addUser };
|
||||
}
|
||||
32
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/models.ts
vendored
Normal file
32
gitnexus/test/fixtures/vue-scope/vue-cross-file/src/models.ts
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
17
gitnexus/test/fixtures/vue-scope/vue-options-api/src/App.vue
vendored
Normal file
17
gitnexus/test/fixtures/vue-scope/vue-options-api/src/App.vue
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<Counter :initialValue="0" :step="1" />
|
||||
<TodoList />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import Counter from './Counter.vue';
|
||||
import TodoList from './TodoList.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
components: { Counter, TodoList },
|
||||
});
|
||||
</script>
|
||||
42
gitnexus/test/fixtures/vue-scope/vue-options-api/src/Counter.vue
vendored
Normal file
42
gitnexus/test/fixtures/vue-scope/vue-options-api/src/Counter.vue
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div class="counter">
|
||||
<button @click="decrement">-</button>
|
||||
<span>{{ count }}</span>
|
||||
<button @click="increment">+</button>
|
||||
<button @click="reset">Reset</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Counter',
|
||||
props: {
|
||||
initialValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
count: this.initialValue,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
increment() {
|
||||
this.count += this.step;
|
||||
},
|
||||
decrement() {
|
||||
this.count -= this.step;
|
||||
},
|
||||
reset() {
|
||||
this.count = this.initialValue;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
51
gitnexus/test/fixtures/vue-scope/vue-options-api/src/TodoList.vue
vendored
Normal file
51
gitnexus/test/fixtures/vue-scope/vue-options-api/src/TodoList.vue
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<template>
|
||||
<div class="todo-list">
|
||||
<input v-model="newTodoText" @keyup.enter="addTodo" />
|
||||
<ul>
|
||||
<li v-for="todo in pendingTodos" :key="todo.id" @click="toggleItem(todo)">
|
||||
{{ todo.text }}
|
||||
</li>
|
||||
</ul>
|
||||
<p>Done: {{ doneCount }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { Todo } from './utils';
|
||||
import { createTodo, toggleTodo, filterDone, filterPending } from './utils';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TodoList',
|
||||
data() {
|
||||
return {
|
||||
newTodoText: '',
|
||||
todos: [] as Todo[],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
doneCount(): number {
|
||||
return filterDone(this.todos).length;
|
||||
},
|
||||
pendingTodos(): Todo[] {
|
||||
return filterPending(this.todos);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addTodo() {
|
||||
if (this.newTodoText.trim() === '') return;
|
||||
this.todos.push(createTodo(this.newTodoText));
|
||||
this.newTodoText = '';
|
||||
},
|
||||
toggleItem(todo: Todo) {
|
||||
const idx = this.todos.findIndex((t) => t.id === todo.id);
|
||||
if (idx !== -1) {
|
||||
this.todos[idx] = toggleTodo(todo);
|
||||
}
|
||||
},
|
||||
clearDone() {
|
||||
this.todos = filterPending(this.todos);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
21
gitnexus/test/fixtures/vue-scope/vue-options-api/src/utils.ts
vendored
Normal file
21
gitnexus/test/fixtures/vue-scope/vue-options-api/src/utils.ts
vendored
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -160,6 +160,55 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
// propagation in the legacy DAG.
|
||||
'resolves caller.fooService.getUser() to FooService.getUser via constructor-inferred typeBinding',
|
||||
'resolves caller.fooService.getUser() through the factory chain to FooService.getUser',
|
||||
// HOC-wrapped variable declarations (typescript-hoc-wrapped.test.ts).
|
||||
// The legacy DAG's `tsExtractFunctionName` only walks `pair` /
|
||||
// `variable_declarator` parents — `arguments` parents fall through with
|
||||
// `funcName = null`, so HOC-wrapped const declarations (forwardRef, memo,
|
||||
// useCallback, useMemo, observer, debounce) are anonymous in legacy and
|
||||
// their inner calls are attributed to the File node or not at all.
|
||||
// The scope-resolver names these via `@declaration.function` matching on
|
||||
// `lexical_declaration > variable_declarator > call_expression > arguments
|
||||
// > arrow_function`. Scope-resolver-only correctness wins; backporting the
|
||||
// HOC-wrapping traversal to the legacy DAG is out of scope.
|
||||
'React.forwardRef: Button → cn and Button → helper (member-expression callee)',
|
||||
'memo (bare identifier): Card → cn and Card → helper',
|
||||
'useCallback: handleClick → doStuff and handleClick → fmt',
|
||||
'useCallback: handleSubmit → doStuff (sibling const, separate caller)',
|
||||
'useMemo: computed → doStuff (returns-a-value variant)',
|
||||
'observer (MobX): Item → helper',
|
||||
'debounce: debouncedSearch → doStuff (utility-HOC form)',
|
||||
'bare statement-level HOC calls do not produce phantom Functions',
|
||||
'handleClick and handleSubmit do not cross-attribute (no first-sibling-wins)',
|
||||
'nested HOCs: helper() call inside the deepest arrow does NOT source from Function:Wrapped',
|
||||
'export default HOC: calls attribute to the file-derived function name',
|
||||
// HOF-callback CALLS edges (typescript-hof-callbacks.test.ts).
|
||||
// The legacy DAG attributed calls inside pair-arrow / executor / .map
|
||||
// callbacks to the outermost module scope instead of the named arrow
|
||||
// function. The scope-resolver uses `pass2AttachDeclarations` to place
|
||||
// the Function def on the inner arrow, correctly attributing inner calls.
|
||||
// Scope-resolver-only correctness wins; backporting the pair-arrow / HOF
|
||||
// attribution fix to the legacy DAG is out of scope.
|
||||
'control: direct (x) => transform(x) emits direct → transform',
|
||||
'Promise.all(map(...)) emits fanOut → transform (call inside .map callback)',
|
||||
'new Promise((resolve) => { ... }) emits wrap → transform (call inside executor)',
|
||||
'useQuery({ queryFn: () => fetchData() }) emits queryFn → fetchData (call inside named pair-arrow)',
|
||||
'useQuery({ queryFn: () => fetchData() }) emits useFeature → useQuery (direct call in body)',
|
||||
'Zustand module-level calls source from the File node (not a sibling Function)',
|
||||
'transform is reachable from at least 3 of {direct, fanOut, wrap}',
|
||||
'multi-action store: addItem → doA (calls inside addItem attribute to addItem, not first sibling)',
|
||||
'multi-action store: removeItem → doB (NOT addItem → doB)',
|
||||
'multi-action store: fetchData → doC (third action also attributes correctly)',
|
||||
'multi-action store: each action attributes calls to itself (no cross-sibling leakage)',
|
||||
// JSX-as-call CALLS edges (typescript-jsx-as-call.test.ts).
|
||||
// The legacy DAG had no `jsx_*` patterns in the TS scope query, so
|
||||
// `<Foo />` / `<Foo>...</Foo>` produced no CALLS edges. The scope-resolver
|
||||
// added `jsx_self_closing_element` and `jsx_opening_element` captures.
|
||||
// Scope-resolver-only correctness wins; backporting JSX capture to the
|
||||
// legacy DAG query is out of scope.
|
||||
'self-closing <Foo /> emits useFoo → Foo',
|
||||
'paired <Bar>...</Bar> emits useBar → Bar (closing tag does NOT double-count)',
|
||||
'nested <Outer><Inner /></Outer> emits both useNested → Outer AND useNested → Inner',
|
||||
'combined HOF + JSX: const Wrapped = () => <Foo /> emits exactly one Wrapped → Foo',
|
||||
]),
|
||||
javascript: new Set([
|
||||
// Mirrors the TypeScript class-instance and factory-pattern singleton
|
||||
|
|
@ -334,6 +383,30 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
// the `self.base()` call unresolved for this fixture.
|
||||
'resolves self.base() inside added() to Bar.base (self == Bar), not Foo',
|
||||
]),
|
||||
vue: new Set<string>([
|
||||
// Template-derived edges are emitted via `emitPostResolutionEdges` on the
|
||||
// registry-primary path. The legacy resolver never runs this hook, so
|
||||
// these edges are absent on the REGISTRY_PRIMARY_VUE=0 path.
|
||||
'emits CALLS edge from @click="handleSave" in UserProfile.vue template',
|
||||
'emits CALLS edge from @keyup.enter="addTodo" in TodoList.vue template',
|
||||
'emits ACCESSES edge for :userId="currentUserId" in App.vue template',
|
||||
'emits ACCESSES edge for :posts="allPosts" in App.vue template',
|
||||
// Component event-system edges (BINDS_EVENT_HANDLER / EMITS_EVENT) are
|
||||
// registry-primary-only — the legacy resolver has no equivalent.
|
||||
'emits BINDS_EVENT_HANDLER from onPostSelected to PostList (component event)',
|
||||
'emits BINDS_EVENT_HANDLER from onUserLoaded to UserCard (component event)',
|
||||
'emits EMITS_EVENT from PostList.vue for emit("select")',
|
||||
'emits EMITS_EVENT from UserCard.vue for emit("loaded")',
|
||||
// Legacy DAG over-resolves this via import/global fallback from the
|
||||
// composable return object; registry-primary keeps this unresolved.
|
||||
'does not currently emit CALLS edge to addUser returned from useUserList',
|
||||
// <script setup> implicit-export detection: the scope-based path marks
|
||||
// all top-level <script setup> bindings as exported; the legacy path
|
||||
// relies on per-node isExported flags from the parse worker which may
|
||||
// not propagate correctly through the legacy resolver flow.
|
||||
'marks <script setup> top-level functions as exported',
|
||||
'marks <script setup> top-level functions in PostList as exported',
|
||||
]),
|
||||
cpp: new Set<string>([
|
||||
// The legacy DAG path has no scope-aware filtering on the global
|
||||
// free-call fallback, so `#include`d headers still leak class
|
||||
|
|
|
|||
|
|
@ -36,9 +36,10 @@
|
|||
* Each test fixture below isolates one wrapper shape with the call
|
||||
* target defined in `helpers.ts` (cross-file resolution).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
createResolverParityIt,
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
edgeSet,
|
||||
|
|
@ -48,6 +49,8 @@ import {
|
|||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
const it = createResolverParityIt('typescript');
|
||||
|
||||
describe('TypeScript HOC-wrapped variable declarations', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,16 +34,19 @@
|
|||
* Each test fixture below isolates one HOF-callback shape from the bug
|
||||
* report with both caller and callee defined in-fixture.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
createResolverParityIt,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
const it = createResolverParityIt('typescript');
|
||||
|
||||
describe('TypeScript HOF-callback CALLS edges', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
|
|
|
|||
|
|
@ -21,16 +21,19 @@
|
|||
* - HTML-only `<div>`/`<span>` — html-only.tsx (negative test)
|
||||
* - HOF + JSX `const F = () => <X/>` — hof-jsx.tsx (combined-fix probe)
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
createResolverParityIt,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
const it = createResolverParityIt('typescript');
|
||||
|
||||
describe('TypeScript JSX-as-call CALLS edges', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
|
|
|
|||
482
gitnexus/test/integration/resolvers/vue-scope.test.ts
Normal file
482
gitnexus/test/integration/resolvers/vue-scope.test.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
/**
|
||||
* Vue SFC: scope-based resolution (RFC #909 Ring 3, issue #940).
|
||||
*
|
||||
* Three fixture repos covering the main Vue SFC patterns:
|
||||
*
|
||||
* - vue-composition-api — `<script setup lang="ts">` with cross-file
|
||||
* imports, computed refs, defineProps/defineEmits macros.
|
||||
* - vue-options-api — `<script lang="ts">` with defineComponent,
|
||||
* data()/methods/computed; `this.X()` method calls.
|
||||
* - vue-cross-file — composable functions, class models, multi-
|
||||
* component app with cross-file CALLS chains.
|
||||
*
|
||||
* The `createResolverParityIt` wrapper runs each test under BOTH the
|
||||
* legacy DAG path (REGISTRY_PRIMARY_VUE=0) and the registry-primary
|
||||
* path (default) so the CI scope-parity gate can compare them.
|
||||
*/
|
||||
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
runPipelineFromRepo,
|
||||
createResolverParityIt,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
const VUE_SCOPE_FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'vue-scope');
|
||||
|
||||
const it = createResolverParityIt('vue');
|
||||
|
||||
// ─── Composition API (`<script setup lang="ts">`) ───────────────────────────
|
||||
|
||||
describe('Vue Composition API (<script setup>)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(VUE_SCOPE_FIXTURES, 'vue-composition-api'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
// Symbol extraction --------------------------------------------------------
|
||||
|
||||
it('extracts Function nodes from <script setup> components', () => {
|
||||
const fns = getNodesByLabel(result, 'Function');
|
||||
expect(fns).toContain('loadData');
|
||||
expect(fns).toContain('handleSave');
|
||||
expect(fns).toContain('selectPost');
|
||||
expect(fns).toContain('getLabel');
|
||||
expect(fns).toContain('onPostSelected');
|
||||
});
|
||||
|
||||
it('extracts Function nodes from .ts utility files', () => {
|
||||
const fns = getNodesByLabel(result, 'Function');
|
||||
expect(fns).toContain('formatUser');
|
||||
expect(fns).toContain('formatPost');
|
||||
expect(fns).toContain('fetchUser');
|
||||
expect(fns).toContain('fetchPosts');
|
||||
expect(fns).toContain('saveUser');
|
||||
});
|
||||
|
||||
it('extracts Interface nodes from .ts files', () => {
|
||||
const ifaces = getNodesByLabel(result, 'Interface');
|
||||
expect(ifaces).toContain('User');
|
||||
expect(ifaces).toContain('Post');
|
||||
});
|
||||
|
||||
// Import resolution --------------------------------------------------------
|
||||
|
||||
it('resolves value imports from UserProfile.vue to types.ts', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
// File-level IMPORTS edge: multiple imported symbols collapse to one edge.
|
||||
const vueToTypes = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('UserProfile.vue') && e.targetFilePath.endsWith('types.ts'),
|
||||
);
|
||||
expect(vueToTypes.length).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves value imports from UserProfile.vue to api.ts', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
// File-level IMPORTS edge: multiple imported symbols collapse to one edge.
|
||||
const vueToApi = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('UserProfile.vue') && e.targetFilePath.endsWith('api.ts'),
|
||||
);
|
||||
expect(vueToApi.length).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves default import from App.vue to UserProfile.vue', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
// import UserProfile from './UserProfile.vue' → 1 default-import edge
|
||||
const vueToVue = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('UserProfile.vue'),
|
||||
);
|
||||
expect(vueToVue.length).toBe(1);
|
||||
});
|
||||
|
||||
// CALLS edges --------------------------------------------------------------
|
||||
|
||||
it('emits CALLS edge from <script setup> to imported formatUser', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toFormatUser = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('UserProfile.vue') && e.target === 'formatUser',
|
||||
);
|
||||
expect(toFormatUser.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from <script setup> to imported fetchUser', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toFetchUser = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('UserProfile.vue') && e.target === 'fetchUser',
|
||||
);
|
||||
expect(toFetchUser.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from <script setup> to imported saveUser', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toSaveUser = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('UserProfile.vue') && e.target === 'saveUser',
|
||||
);
|
||||
expect(toSaveUser.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from PostList.vue to formatPost', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toFormatPost = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('PostList.vue') && e.target === 'formatPost',
|
||||
);
|
||||
expect(toFormatPost.length).toBe(1);
|
||||
});
|
||||
|
||||
// <script setup> top-level export ------------------------------------------
|
||||
|
||||
it('marks <script setup> top-level functions as exported', () => {
|
||||
const allFns = getNodesByLabelFull(result, 'Function');
|
||||
const loadData = allFns.find(
|
||||
(n) => n.properties.name === 'loadData' && n.properties.filePath.endsWith('UserProfile.vue'),
|
||||
);
|
||||
expect(loadData).toBeDefined();
|
||||
expect(loadData!.properties.isExported).toBe(true);
|
||||
});
|
||||
|
||||
it('marks <script setup> top-level functions in PostList as exported', () => {
|
||||
const allFns = getNodesByLabelFull(result, 'Function');
|
||||
const selectPost = allFns.find(
|
||||
(n) => n.properties.name === 'selectPost' && n.properties.filePath.endsWith('PostList.vue'),
|
||||
);
|
||||
expect(selectPost).toBeDefined();
|
||||
expect(selectPost!.properties.isExported).toBe(true);
|
||||
});
|
||||
|
||||
// Template event-handler CALLS --------------------------------------------
|
||||
|
||||
it('emits CALLS edge from @click="handleSave" in UserProfile.vue template', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const templateToSave = calls.filter(
|
||||
(e) =>
|
||||
e.sourceFilePath.endsWith('UserProfile.vue') &&
|
||||
e.target === 'handleSave' &&
|
||||
e.rel.reason === 'vue-template-callback',
|
||||
);
|
||||
expect(templateToSave.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits BINDS_EVENT_HANDLER from onPostSelected to PostList (component event)', () => {
|
||||
const bindings = getRelationships(result, 'BINDS_EVENT_HANDLER');
|
||||
const toPostList = bindings.filter(
|
||||
(e) =>
|
||||
e.sourceFilePath.endsWith('App.vue') &&
|
||||
e.source === 'onPostSelected' &&
|
||||
e.targetFilePath.endsWith('PostList.vue') &&
|
||||
e.rel.reason === 'vue-event: @select',
|
||||
);
|
||||
expect(toPostList.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits EMITS_EVENT from PostList.vue for emit("select")', () => {
|
||||
const emits = getRelationships(result, 'EMITS_EVENT');
|
||||
const postListEmit = emits.filter(
|
||||
(e) => e.sourceFilePath.endsWith('PostList.vue') && e.rel.reason === 'vue-emit: select',
|
||||
);
|
||||
expect(postListEmit.length).toBe(1);
|
||||
});
|
||||
|
||||
// Template attribute-binding ACCESSES -------------------------------------
|
||||
|
||||
it('emits ACCESSES edge for :userId="currentUserId" in App.vue template', () => {
|
||||
const accesses = getRelationships(result, 'ACCESSES');
|
||||
const attrAccess = accesses.filter(
|
||||
(e) =>
|
||||
e.sourceFilePath.endsWith('App.vue') &&
|
||||
e.target === 'currentUserId' &&
|
||||
e.rel.reason === 'vue-template-attribute',
|
||||
);
|
||||
expect(attrAccess.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits ACCESSES edge for :posts="allPosts" in App.vue template', () => {
|
||||
const accesses = getRelationships(result, 'ACCESSES');
|
||||
const attrAccess = accesses.filter(
|
||||
(e) =>
|
||||
e.sourceFilePath.endsWith('App.vue') &&
|
||||
e.target === 'allPosts' &&
|
||||
e.rel.reason === 'vue-template-attribute',
|
||||
);
|
||||
expect(attrAccess.length).toBe(1);
|
||||
});
|
||||
|
||||
// File nodes ---------------------------------------------------------------
|
||||
|
||||
it('creates File nodes for .vue files', () => {
|
||||
const files = getNodesByLabel(result, 'File');
|
||||
expect(files.some((f) => f.endsWith('UserProfile.vue'))).toBe(true);
|
||||
expect(files.some((f) => f.endsWith('PostList.vue'))).toBe(true);
|
||||
expect(files.some((f) => f.endsWith('App.vue'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Options API (`<script lang="ts">` + defineComponent) ──────────────────
|
||||
|
||||
describe('Vue Options API (defineComponent)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(VUE_SCOPE_FIXTURES, 'vue-options-api'), () => {});
|
||||
}, 60000);
|
||||
|
||||
// Symbol extraction --------------------------------------------------------
|
||||
|
||||
it('extracts Method nodes from methods block', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('addTodo');
|
||||
expect(methods).toContain('toggleItem');
|
||||
expect(methods).toContain('clearDone');
|
||||
expect(methods).toContain('increment');
|
||||
expect(methods).toContain('decrement');
|
||||
expect(methods).toContain('reset');
|
||||
});
|
||||
|
||||
it('extracts utility functions from .ts file', () => {
|
||||
const fns = getNodesByLabel(result, 'Function');
|
||||
expect(fns).toContain('createTodo');
|
||||
expect(fns).toContain('toggleTodo');
|
||||
expect(fns).toContain('filterDone');
|
||||
expect(fns).toContain('filterPending');
|
||||
});
|
||||
|
||||
it('extracts Interface node for Todo', () => {
|
||||
const ifaces = getNodesByLabel(result, 'Interface');
|
||||
expect(ifaces).toContain('Todo');
|
||||
});
|
||||
|
||||
// Import resolution --------------------------------------------------------
|
||||
|
||||
it('resolves value imports from TodoList.vue to utils.ts', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
// File-level IMPORTS edge: multiple imported symbols collapse to one edge.
|
||||
const vueToUtils = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('TodoList.vue') && e.targetFilePath.endsWith('utils.ts'),
|
||||
);
|
||||
expect(vueToUtils.length).toBe(1);
|
||||
});
|
||||
|
||||
// CALLS edges --------------------------------------------------------------
|
||||
|
||||
it('emits CALLS edge from addTodo in TodoList.vue to createTodo', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toCreateTodo = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('TodoList.vue') && e.target === 'createTodo',
|
||||
);
|
||||
expect(toCreateTodo.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from TodoList.vue to filterDone (computed doneCount)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toFilterDone = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('TodoList.vue') && e.target === 'filterDone',
|
||||
);
|
||||
expect(toFilterDone.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from TodoList.vue to filterPending (computed pendingTodos)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toFilterPending = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('TodoList.vue') && e.target === 'filterPending',
|
||||
);
|
||||
// Two call sites in the same file: `pendingTodos` and `clearDone`.
|
||||
expect(toFilterPending.length).toBe(2);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from clearDone to filterPending', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toClearDone = calls.filter(
|
||||
(e) => e.source === 'clearDone' && e.target === 'filterPending',
|
||||
);
|
||||
expect(toClearDone.length).toBe(1);
|
||||
});
|
||||
|
||||
// Non-setup scripts should not be implicitly exported ----------------------
|
||||
|
||||
it('does not mark non-setup <script> methods as implicitly exported', () => {
|
||||
const allFns = getNodesByLabelFull(result, 'Function');
|
||||
const addTodo = allFns.find(
|
||||
(n) => n.properties.name === 'addTodo' && n.properties.filePath.endsWith('TodoList.vue'),
|
||||
);
|
||||
if (addTodo !== undefined) {
|
||||
expect(addTodo.properties.isExported).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Template event-handler CALLS --------------------------------------------
|
||||
|
||||
it('emits CALLS edge from @keyup.enter="addTodo" in TodoList.vue template', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const templateToAdd = calls.filter(
|
||||
(e) =>
|
||||
e.sourceFilePath.endsWith('TodoList.vue') &&
|
||||
e.target === 'addTodo' &&
|
||||
e.rel.reason === 'vue-template-callback',
|
||||
);
|
||||
expect(templateToAdd.length).toBe(1);
|
||||
});
|
||||
|
||||
// File nodes ---------------------------------------------------------------
|
||||
|
||||
it('creates File nodes for Options API .vue files', () => {
|
||||
const files = getNodesByLabel(result, 'File');
|
||||
expect(files.some((f) => f.endsWith('TodoList.vue'))).toBe(true);
|
||||
expect(files.some((f) => f.endsWith('Counter.vue'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cross-file: composables + class models ─────────────────────────────────
|
||||
|
||||
describe('Vue cross-file composable and class resolution', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(VUE_SCOPE_FIXTURES, 'vue-cross-file'), () => {});
|
||||
}, 60000);
|
||||
|
||||
// Symbol extraction --------------------------------------------------------
|
||||
|
||||
it('extracts Class nodes from .ts model file', () => {
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('UserModel');
|
||||
expect(classes).toContain('PostModel');
|
||||
});
|
||||
|
||||
it('extracts Method nodes from UserModel', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('isAdmin');
|
||||
expect(methods).toContain('displayName');
|
||||
});
|
||||
|
||||
it('extracts Method nodes from PostModel', () => {
|
||||
const methods = getNodesByLabel(result, 'Method');
|
||||
expect(methods).toContain('summary');
|
||||
expect(methods).toContain('wordCount');
|
||||
});
|
||||
|
||||
it('extracts composable functions from useUser.ts', () => {
|
||||
const fns = getNodesByLabel(result, 'Function');
|
||||
expect(fns).toContain('useUser');
|
||||
expect(fns).toContain('useUserList');
|
||||
});
|
||||
|
||||
it('extracts composable function usePost from usePost.ts', () => {
|
||||
const fns = getNodesByLabel(result, 'Function');
|
||||
expect(fns).toContain('usePost');
|
||||
});
|
||||
|
||||
// Import resolution --------------------------------------------------------
|
||||
|
||||
it('resolves import from useUser.ts to models.ts (1 named export: UserModel)', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const compToModel = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('useUser.ts') && e.targetFilePath.endsWith('models.ts'),
|
||||
);
|
||||
expect(compToModel.length).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves import from UserCard.vue to useUser.ts (1 named export: useUser)', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const vueToComp = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('UserCard.vue') && e.targetFilePath.endsWith('useUser.ts'),
|
||||
);
|
||||
expect(vueToComp.length).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves import from App.vue to useUser.ts (1 named export: useUserList)', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const appToComp = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('useUser.ts'),
|
||||
);
|
||||
expect(appToComp.length).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves import from App.vue to models.ts (1 named export: UserModel)', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const appToModel = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('models.ts'),
|
||||
);
|
||||
expect(appToModel.length).toBe(1);
|
||||
});
|
||||
|
||||
// CALLS edges --------------------------------------------------------------
|
||||
|
||||
it('emits CALLS edge from UserCard.vue to useUser composable', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toUseUser = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('UserCard.vue') && e.target === 'useUser',
|
||||
);
|
||||
expect(toUseUser.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from PostCard.vue to usePost composable', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toUsePost = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('PostCard.vue') && e.target === 'usePost',
|
||||
);
|
||||
expect(toUsePost.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from App.vue to useUserList composable', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toUseUserList = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.target === 'useUserList',
|
||||
);
|
||||
expect(toUseUserList.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge from useUser.ts to UserModel constructor', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toUserModel = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('useUser.ts') && e.target === 'UserModel',
|
||||
);
|
||||
expect(toUserModel.length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not currently emit CALLS edge to addUser returned from useUserList', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const toAddUser = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.target === 'addUser',
|
||||
);
|
||||
expect(toAddUser.length).toBe(0);
|
||||
});
|
||||
|
||||
// Template event-handler CALLS --------------------------------------------
|
||||
|
||||
it('emits BINDS_EVENT_HANDLER from onUserLoaded to UserCard (component event)', () => {
|
||||
const bindings = getRelationships(result, 'BINDS_EVENT_HANDLER');
|
||||
const toUserCard = bindings.filter(
|
||||
(e) =>
|
||||
e.sourceFilePath.endsWith('App.vue') &&
|
||||
e.source === 'onUserLoaded' &&
|
||||
e.targetFilePath.endsWith('UserCard.vue') &&
|
||||
e.rel.reason === 'vue-event: @loaded',
|
||||
);
|
||||
expect(toUserCard.length).toBe(1);
|
||||
});
|
||||
|
||||
it('emits EMITS_EVENT from UserCard.vue for emit("loaded")', () => {
|
||||
const emits = getRelationships(result, 'EMITS_EVENT');
|
||||
const userCardEmit = emits.filter(
|
||||
(e) => e.sourceFilePath.endsWith('UserCard.vue') && e.rel.reason === 'vue-emit: loaded',
|
||||
);
|
||||
expect(userCardEmit.length).toBe(1);
|
||||
});
|
||||
|
||||
// File nodes ---------------------------------------------------------------
|
||||
|
||||
it('creates File nodes for all .vue and .ts files', () => {
|
||||
const files = getNodesByLabel(result, 'File');
|
||||
expect(files.some((f) => f.endsWith('UserCard.vue'))).toBe(true);
|
||||
expect(files.some((f) => f.endsWith('PostCard.vue'))).toBe(true);
|
||||
expect(files.some((f) => f.endsWith('useUser.ts'))).toBe(true);
|
||||
expect(files.some((f) => f.endsWith('models.ts'))).toBe(true);
|
||||
});
|
||||
});
|
||||
199
gitnexus/test/integration/vue-pipeline-benchmark.test.ts
Normal file
199
gitnexus/test/integration/vue-pipeline-benchmark.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
/**
|
||||
* Unit tests for `registry-primary-flag` (RFC #909 Ring 2 PKG #924).
|
||||
*
|
||||
* Flag is `REGISTRY_PRIMARY_<UPPER(lang)>`. Each test manipulates
|
||||
* `process.env` directly and restores it in `afterEach` — there is no
|
||||
* per-process cache to invalidate, so isolation is lexical.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import {
|
||||
envVarNameFor,
|
||||
isRegistryPrimary,
|
||||
primaryLanguages,
|
||||
MIGRATED_LANGUAGES,
|
||||
} from '../../src/core/ingestion/registry-primary-flag.js';
|
||||
|
||||
// ─── Test isolation ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Scrub every `REGISTRY_PRIMARY_*` env var before + after each test so
|
||||
// parallel vitest runs on the same process don't bleed state.
|
||||
|
||||
function clearAllRegistryPrimaryVars(): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith('REGISTRY_PRIMARY_')) delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(clearAllRegistryPrimaryVars);
|
||||
afterEach(clearAllRegistryPrimaryVars);
|
||||
|
||||
// ─── envVarNameFor ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('envVarNameFor', () => {
|
||||
it('produces upper-cased env-var names from the enum value', () => {
|
||||
expect(envVarNameFor(SupportedLanguages.Python)).toBe('REGISTRY_PRIMARY_PYTHON');
|
||||
expect(envVarNameFor(SupportedLanguages.TypeScript)).toBe('REGISTRY_PRIMARY_TYPESCRIPT');
|
||||
expect(envVarNameFor(SupportedLanguages.JavaScript)).toBe('REGISTRY_PRIMARY_JAVASCRIPT');
|
||||
});
|
||||
|
||||
it('uses the enum VALUE, not the key, for languages whose key differs from the value', () => {
|
||||
// Key 'CPlusPlus' → value 'cpp' → env var 'REGISTRY_PRIMARY_CPP'.
|
||||
// Users see the language by its canonical name, not its TS symbol.
|
||||
expect(envVarNameFor(SupportedLanguages.CPlusPlus)).toBe('REGISTRY_PRIMARY_CPP');
|
||||
expect(envVarNameFor(SupportedLanguages.CSharp)).toBe('REGISTRY_PRIMARY_CSHARP');
|
||||
});
|
||||
|
||||
it('covers every member of SupportedLanguages', () => {
|
||||
// Build env-var names for every language and assert no duplicates —
|
||||
// catches a future enum-value collision or accidental renaming.
|
||||
const names = new Set<string>();
|
||||
for (const lang of Object.values(SupportedLanguages)) {
|
||||
names.add(envVarNameFor(lang));
|
||||
}
|
||||
expect(names.size).toBe(Object.values(SupportedLanguages).length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isRegistryPrimary ─────────────────────────────────────────────────────
|
||||
|
||||
describe('isRegistryPrimary', () => {
|
||||
it('returns MIGRATED_LANGUAGES membership by default (no env var set)', () => {
|
||||
// Ring 3: languages in MIGRATED_LANGUAGES are registry-primary by
|
||||
// default — operators don't need to set an env var for the rolled-out
|
||||
// migration to take effect. Unmigrated languages default to false.
|
||||
for (const lang of Object.values(SupportedLanguages)) {
|
||||
expect(isRegistryPrimary(lang)).toBe(MIGRATED_LANGUAGES.has(lang));
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true when the env var is 'true' (lowercase)", () => {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when the env var is '1'", () => {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = '1';
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when the env var is 'yes'", () => {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = 'yes';
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts mixed-case and whitespace-padded truthy values', () => {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = ' TRUE ';
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = 'Yes';
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for falsy-looking values ('false', '0', empty, 'off')", () => {
|
||||
for (const value of ['false', '0', '', 'off', 'no', 'disabled']) {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = value;
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns false for unrecognized tokens (fail-safe on typos)', () => {
|
||||
// User meant to type 'true' but fat-fingered — conservative: treat as off.
|
||||
for (const value of ['ture', 'tru', 'yeah', 'enable', 'y']) {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = value;
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('isolates flags per-language (one on does not affect others)', () => {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
|
||||
// Vue is not in MIGRATED_LANGUAGES — default false stays
|
||||
// false regardless of Python's flag.
|
||||
expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects a mid-process env-var mutation (no stale cache)', () => {
|
||||
// Use Vue — not in MIGRATED_LANGUAGES — so the unset default is
|
||||
// deterministically `false`, independent of which languages have
|
||||
// been flipped to registry-primary.
|
||||
expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false);
|
||||
process.env['REGISTRY_PRIMARY_VUE'] = 'true';
|
||||
expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(true);
|
||||
delete process.env['REGISTRY_PRIMARY_VUE'];
|
||||
expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => {
|
||||
process.env['REGISTRY_PRIMARY_CPP'] = 'true';
|
||||
expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(true);
|
||||
// Negative: the TS-key-style name is NOT read. CPlusPlus is now in
|
||||
// MIGRATED_LANGUAGES, so we must explicitly opt it out via the
|
||||
// canonical env var to verify the wrong-name var has no effect.
|
||||
process.env['REGISTRY_PRIMARY_CPP'] = 'false';
|
||||
process.env['REGISTRY_PRIMARY_CPLUSPLUS'] = 'true';
|
||||
expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── primaryLanguages ──────────────────────────────────────────────────────
|
||||
|
||||
describe('primaryLanguages', () => {
|
||||
it('returns MIGRATED_LANGUAGES when no flags are set', () => {
|
||||
// Default-on for migrated languages (Ring 3); unmigrated stay off.
|
||||
const enabled = primaryLanguages();
|
||||
expect(enabled.size).toBe(MIGRATED_LANGUAGES.size);
|
||||
for (const lang of MIGRATED_LANGUAGES) {
|
||||
expect(enabled.has(lang)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => {
|
||||
// Migrated languages are default-on; each must be opted out here when
|
||||
// testing explicit env overrides. Ruby (unmigrated) opts in.
|
||||
// Opt out every member of MIGRATED_LANGUAGES dynamically so this test
|
||||
// does not have to be updated each time a new language ships its
|
||||
// Ring 3 migration (C++ and PHP joined the set in their respective
|
||||
// Ring 3 migrations; future Ring 3 additions land here without test churn).
|
||||
for (const lang of MIGRATED_LANGUAGES) {
|
||||
process.env[envVarNameFor(lang)] = 'false';
|
||||
}
|
||||
process.env['REGISTRY_PRIMARY_RUBY'] = '1';
|
||||
const enabled = primaryLanguages();
|
||||
expect(enabled.has(SupportedLanguages.Python)).toBe(false);
|
||||
expect(enabled.has(SupportedLanguages.CSharp)).toBe(false);
|
||||
expect(enabled.has(SupportedLanguages.Go)).toBe(false);
|
||||
expect(enabled.has(SupportedLanguages.CPlusPlus)).toBe(false);
|
||||
expect(enabled.has(SupportedLanguages.PHP)).toBe(false);
|
||||
expect(enabled.has(SupportedLanguages.Ruby)).toBe(true);
|
||||
// Only Ruby is on: migrated defaults overridden off, Ruby explicitly on.
|
||||
expect(enabled.size).toBe(1);
|
||||
});
|
||||
|
||||
it('returns a plain Set (not a frozen proxy) — consistent shape', () => {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
|
||||
const enabled = primaryLanguages();
|
||||
expect(enabled).toBeInstanceOf(Set);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest';
|
|||
import {
|
||||
extractVueScript,
|
||||
extractTemplateComponents,
|
||||
extractScriptEmitCalls,
|
||||
extractComponentEventBindings,
|
||||
extractNativeElementEventHandlers,
|
||||
} from '../../src/core/ingestion/vue-sfc-extractor.js';
|
||||
|
||||
describe('extractVueScript', () => {
|
||||
|
|
@ -185,6 +188,140 @@ const x = 1;
|
|||
const components = extractTemplateComponents(vue);
|
||||
expect(components).toEqual(['MyComponent']);
|
||||
});
|
||||
|
||||
it('treats kebab-case component tags as component candidates', () => {
|
||||
const vue = `<template>
|
||||
<div>
|
||||
<post-list />
|
||||
<user-card />
|
||||
</div>
|
||||
</template>`;
|
||||
const components = extractTemplateComponents(vue);
|
||||
expect(components).toContain('PostList');
|
||||
expect(components).toContain('UserCard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractScriptEmitCalls', () => {
|
||||
it('extracts bare emit() event names', () => {
|
||||
const vue = `<script setup lang="ts">
|
||||
const emit = defineEmits(['select']);
|
||||
emit('select', { id: 1 });
|
||||
</script>`;
|
||||
expect(extractScriptEmitCalls(vue).map((c) => c.eventName)).toEqual(['select']);
|
||||
});
|
||||
|
||||
it('ignores property emits and commented/string emit text', () => {
|
||||
const vue = `<script setup lang="ts">
|
||||
const socket = createSocket();
|
||||
socket.emit('message');
|
||||
// emit('commented')
|
||||
const text = "emit('inside-string')";
|
||||
emit('actual');
|
||||
</script>`;
|
||||
expect(extractScriptEmitCalls(vue).map((c) => c.eventName)).toEqual(['actual']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractComponentEventBindings', () => {
|
||||
it('captures kebab-case component event bindings', () => {
|
||||
const vue = `<template>
|
||||
<post-list @select="onPostSelected" />
|
||||
</template>`;
|
||||
expect(extractComponentEventBindings(vue)).toEqual([
|
||||
{ componentName: 'PostList', eventName: 'select', handlerName: 'onPostSelected' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures hyphenated event names (@user-loaded)', () => {
|
||||
const vue = `<template>
|
||||
<UserCard @user-loaded="onUserLoaded" />
|
||||
</template>`;
|
||||
const bindings = extractComponentEventBindings(vue);
|
||||
expect(bindings).toContainEqual({
|
||||
componentName: 'UserCard',
|
||||
eventName: 'user-loaded',
|
||||
handlerName: 'onUserLoaded',
|
||||
});
|
||||
});
|
||||
|
||||
it('captures update:model-value style event names', () => {
|
||||
const vue = `<template>
|
||||
<MyInput @update:model-value="onChange" />
|
||||
</template>`;
|
||||
const bindings = extractComponentEventBindings(vue);
|
||||
expect(bindings).toContainEqual({
|
||||
componentName: 'MyInput',
|
||||
eventName: 'update:model-value',
|
||||
handlerName: 'onChange',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNativeElementEventHandlers', () => {
|
||||
it('captures handlers from native elements', () => {
|
||||
const vue = `<template>
|
||||
<button @click="handleSave" />
|
||||
<form @submit.prevent="onSubmit" />
|
||||
</template>`;
|
||||
const handlers = extractNativeElementEventHandlers(vue);
|
||||
expect(handlers).toContain('handleSave');
|
||||
expect(handlers).toContain('onSubmit');
|
||||
});
|
||||
|
||||
it('does not emit handlers for kebab-case component tags', () => {
|
||||
// <post-list> is a Vue component, not a native element.
|
||||
// The NATIVE_TAG_RE negative lookahead must prevent matching `post` as a native tag.
|
||||
const vue = `<template>
|
||||
<post-list @select="onSelect" />
|
||||
<button @click="handleClick" />
|
||||
</template>`;
|
||||
const handlers = extractNativeElementEventHandlers(vue);
|
||||
expect(handlers).not.toContain('onSelect');
|
||||
expect(handlers).toContain('handleClick');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractScriptEmitCalls — Options API this.$emit', () => {
|
||||
it('captures this.$emit() in Options API components', () => {
|
||||
const vue = `<script lang="ts">
|
||||
export default {
|
||||
methods: {
|
||||
save() {
|
||||
this.$emit('save');
|
||||
this.$emit('update:modelValue', this.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>`;
|
||||
const events = extractScriptEmitCalls(vue).map((c) => c.eventName);
|
||||
expect(events).toContain('save');
|
||||
expect(events).toContain('update:modelValue');
|
||||
});
|
||||
|
||||
it('does NOT capture socket.emit() or eventBus.emit() as component events', () => {
|
||||
const vue = `<script setup lang="ts">
|
||||
const socket = getSocket();
|
||||
socket.emit('message');
|
||||
eventBus.emit('data');
|
||||
emit('actual');
|
||||
</script>`;
|
||||
const events = extractScriptEmitCalls(vue).map((c) => c.eventName);
|
||||
expect(events).toEqual(['actual']);
|
||||
expect(events).not.toContain('message');
|
||||
expect(events).not.toContain('data');
|
||||
});
|
||||
|
||||
it('captures update:modelValue style event names with colon', () => {
|
||||
const vue = `<script setup lang="ts">
|
||||
const emit = defineEmits(['update:modelValue', 'user-loaded']);
|
||||
emit('update:modelValue', newVal);
|
||||
emit('user-loaded');
|
||||
</script>`;
|
||||
const events = extractScriptEmitCalls(vue).map((c) => c.eventName);
|
||||
expect(events).toContain('update:modelValue');
|
||||
expect(events).toContain('user-loaded');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue