Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion gitnexus-shared/src/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 24 additions & 13 deletions gitnexus/scripts/run-parity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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})`);
}
}

Expand All @@ -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═══════════════════════════════════════');
Expand Down
13 changes: 13 additions & 0 deletions gitnexus/src/core/ingestion/language-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

/**
Expand Down
21 changes: 21 additions & 0 deletions gitnexus/src/core/ingestion/languages/vue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0 · CI-reproduced] vueProvider registers emitScopeCaptures but omits the four scope-resolution hooks the TypeScript provider supplies — interpretImport, interpretTypeBinding, bindingScopeFor, importOwningScope (typescript.ts:317–320). The legacy importResolver/namedBindingExtractor on lines 77–78 feed the old DAG path, not the registry-primary scope path. pass3CollectImports early-returns when interpretImport is undefined (scope-extractor.ts:824), so parsedImports stays empty → zero IMPORTS and zero cross-file CALLS edges for every Vue file. Reproduced by CI run 26712104950: vue.test.ts 4 failures + vue-scope.test.ts 20/38. Independently found by Codex and the correctness + adversarial lanes.

Fix: add the four hooks (already exported from languages/typescript) to vueProvider. [reproduced]

interpretImport: interpretTsImport,
interpretTypeBinding: interpretTsTypeBinding,
bindingScopeFor: tsBindingScopeFor,
importOwningScope: tsImportOwningScope,
receiverBinding: tsReceiverBinding,
mergeBindings: (_scope, bindings) => typescriptMergeBindings(bindings),
arityCompatibility: typescriptArityCompatibility,
resolveImportTarget: resolveTsImportTarget,
});
67 changes: 67 additions & 0 deletions gitnexus/src/core/ingestion/languages/vue/captures.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 81 additions & 0 deletions gitnexus/src/core/ingestion/languages/vue/import-target.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 50 additions & 0 deletions gitnexus/src/core/ingestion/languages/vue/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading