Skip to content
Merged
16 changes: 13 additions & 3 deletions gitnexus/src/core/ingestion/languages/vue/captures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import type { CaptureMatch } from 'gitnexus-shared';
import { extractVueScript } from '../../vue-sfc-extractor.js';
import { emitTsScopeCaptures } from '../typescript/captures.js';
import { emitJsScopeCaptures } from '../javascript/captures.js';

/**
* Emit scope captures for a Vue SFC.
Expand All @@ -31,11 +32,11 @@ import { emitTsScopeCaptures } from '../typescript/captures.js';
* 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.
* `emitTsScopeCaptures` or `emitJsScopeCaptures` based on `lang`.
*
* 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
* `extractParsedFile`, so `sourceText` is already the bare script
* text with no `<script>` tags. The caller marks this explicitly via
* `sourceMeta.sourceKind === 'pre-extracted-script'`.
*
Expand All @@ -48,7 +49,7 @@ export function emitVueScopeCaptures(
sourceText: string,
filePath: string,
cachedTree?: unknown,
sourceMeta?: { sourceKind?: 'full-file' | 'pre-extracted-script' },
sourceMeta?: { sourceKind?: 'full-file' | 'pre-extracted-script'; setupLang?: string },
): 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
Expand All @@ -58,10 +59,19 @@ export function emitVueScopeCaptures(
}

if (sourceMeta?.sourceKind === 'pre-extracted-script') {
// Worker-mode path: the parse worker always uses TypeScript grammar for
// .vue files. Lang-based grammar selection is a sequential-path feature.
return emitTsScopeCaptures(sourceText, filePath, cachedTree);
}

const extracted = extractVueScript(sourceText);
if (extracted === null) return [];

// Select captures based on script lang attribute.
// Use TS grammar unless ALL blocks explicitly request JS/JSX.
// Mixed-lang: TS handles JS natively; JS grammar chokes on TS syntax.
if (extracted.lang === 'js' || extracted.lang === 'jsx') {
return emitJsScopeCaptures(extracted.scriptContent, filePath, cachedTree);
}
return emitTsScopeCaptures(extracted.scriptContent, filePath, cachedTree);
}
35 changes: 22 additions & 13 deletions gitnexus/src/core/ingestion/vue-sfc-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
export interface VueScriptExtraction {
/** Extracted script content (TypeScript/JavaScript) */
scriptContent: string;
/** 0-based line number in the .vue file where the script content starts */
/** 1-based line number in the .vue file where the script content starts
* (used as offset from tree-sitter's 0-based row). */
lineOffset: number;
/** true if the primary block is <script setup> */
/** true if at least one block is <script setup> */
isSetup: boolean;
/** Value of the `lang` attribute on the extracted script block (e.g. "ts", "js", "tsx", "jsx", or "" for default). */
lang: string;
}

interface ScriptBlock {
Expand Down Expand Up @@ -238,11 +241,9 @@ function parseScriptBlock(
/**
* Extract script content from a Vue SFC.
*
* When both <script> and <script setup> are present, returns only the
* <script setup> block (the dominant pattern — 94% of Vue files in real
* projects use setup). The <script> (non-setup) block typically contains
* only `defineOptions` or legacy option merges and is less important for
* the knowledge graph.
* When both <script> and <script setup> are present, the content of all
* blocks is combined (non-setup first, then setup) so the full script
* surface is available to the knowledge graph.
*/
export function extractVueScript(vueContent: string): VueScriptExtraction | null {
const blocks: ScriptBlock[] = [];
Expand All @@ -257,14 +258,22 @@ export function extractVueScript(vueContent: string): VueScriptExtraction | null

if (blocks.length === 0) return null;

// Prefer <script setup> if present
const setupBlock = blocks.find((b) => b.isSetup);
const primary = setupBlock ?? blocks[0];
// Merge all blocks: non-setup first, then setup, so content order is stable.
const nonSetupBlocks = blocks.filter((b) => !b.isSetup);
const setupBlocks = blocks.filter((b) => b.isSetup);
const ordered = [...nonSetupBlocks, ...setupBlocks];
const combinedContent = ordered.map((b) => b.content).join('\n');
const lineOffset = ordered[0].lineOffset;
// Only use JavaScript grammar when ALL blocks are explicitly lang="js"
// or lang="jsx". If any block omits lang or uses ts/tsx, TypeScript wins.
const allBlocksJs = blocks.length > 0 && blocks.every((b) => b.lang === 'js' || b.lang === 'jsx');
const lang = allBlocksJs ? 'js' : '';

return {
scriptContent: primary.content,
lineOffset: primary.lineOffset,
isSetup: primary.isSetup,
scriptContent: combinedContent,
lineOffset,
isSetup: blocks.some((b) => b.isSetup),
lang,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// F44: Class expression — should produce a Class scope for the expression body
// On main: no @scope.class emitted → methods have no Class parent
// On this branch: (class) @scope.class emitted → methods get Class scope
export const instance = class {
greet(): string {
return 'hello';
}
};
13 changes: 13 additions & 0 deletions gitnexus/test/fixtures/vue-scope/vue-dual-script/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<div>Hello</div>
</template>
<script>
function legacySetup() {
return { count: 0 };
}
</script>
<script setup lang="ts">
function setupInit() {
return 42;
}
</script>
8 changes: 8 additions & 0 deletions gitnexus/test/fixtures/vue-scope/vue-js-lang/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<template>

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.

[P2/P3 — testing, code-read] This fixture (and vue-dual-script/App.vue) is referenced by no test — a repo-wide grep finds zero consumers. The integration suite test/integration/resolvers/vue-scope.test.ts loads fixtures by explicit path and runs only vue-composition-api / vue-options-api / vue-cross-file; these new dirs also lack the src/ mini-repo layout those use. So the JS-lang routing and the dual-script merge are covered only by the pure-extractVueScript unit tests — never end-to-end, and never on the worker path (which would expose the dead setupLang).

Actionable path: add describe blocks to vue-scope.test.ts that run runPipelineFromRepo against these fixtures and assert symbols from both blocks / JS-style captures appear — or remove the fixtures. [code-read]

<div>Hello</div>
</template>
<script lang="js">
function greet(name) {
return 'Hello ' + name;
}
</script>
35 changes: 35 additions & 0 deletions gitnexus/test/integration/resolvers/vue-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,38 @@ describe('Vue cross-file composable and class resolution', () => {
expect(files.some((f) => f.endsWith('models.ts'))).toBe(true);
});
});

// ---------------------------------------------------------------------------
// F90 — dual-script merge (<script> + <script setup>)
// ---------------------------------------------------------------------------

describe('F90 — dual-script (<script> + <script setup>)', () => {
let result: PipelineResult;

beforeAll(async () => {
result = await runPipelineFromRepo(path.join(VUE_SCOPE_FIXTURES, 'vue-dual-script'), () => {});
}, 60000);

it('includes symbols from both the non-setup and setup blocks', () => {
const funcs = getNodesByLabel(result, 'Function');
expect(funcs).toContain('legacySetup');
expect(funcs).toContain('setupInit');
});
});

// ---------------------------------------------------------------------------
// F92 — JS-lang script block (<script lang="js">)
// ---------------------------------------------------------------------------

describe('F92 — JS-lang script block', () => {
let result: PipelineResult;

beforeAll(async () => {
result = await runPipelineFromRepo(path.join(VUE_SCOPE_FIXTURES, 'vue-js-lang'), () => {});
}, 60000);

it('parses <script lang="js"> content and finds functions', () => {
const funcs = getNodesByLabel(result, 'Function');
expect(funcs).toContain('greet');
});
});
74 changes: 71 additions & 3 deletions gitnexus/test/unit/vue-sfc-extractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,12 @@ export default {
expect(result!.scriptContent).toContain('export default');
});

it('prefers <script setup> when both blocks exist', () => {
it('combines both blocks when <script> and <script setup> both exist', () => {
const vue = `<script lang="ts">
export default {
inheritAttrs: false,
};
</script>

<script setup lang="ts">
import { ref } from 'vue';
const name = ref('test');
Expand All @@ -61,9 +60,78 @@ const name = ref('test');
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
// F90: both blocks are combined
expect(result!.isSetup).toBe(true);
expect(result!.scriptContent).toContain("const name = ref('test')");
expect(result!.scriptContent).not.toContain('inheritAttrs');
expect(result!.scriptContent).toContain('inheritAttrs');
});

it('returns lang from the script block', () => {
const vue = `<script lang="js">
export default {};
</script>
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.lang).toBe('js');
});

it('returns empty lang when no lang attribute', () => {
const vue = `<script>
export default {};
</script>
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.lang).toBe('');
});

it('jsx lang triggers JS grammar (maps to lang=js)', () => {
const vue = `<script lang="jsx">
export default {};
</script>
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.lang).toBe('js');
});

it('ts lang returns empty (only js/jsx triggers JS grammar)', () => {
const vue = `<script lang="ts">
export default {};
</script>
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.lang).toBe('');
});

it('mixed js + ts blocks return empty lang (TypeScript wins)', () => {
const vue = `<script lang="js">
export default {};
</script>
<script setup lang="ts">
import { ref } from 'vue';
</script>
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.lang).toBe('');
});

it('isSetup is true when at least one block is setup', () => {
const vue = `<script lang="ts">
export default {};
</script>
<script setup lang="ts">
import { ref } from 'vue';
</script>
`;
const result = extractVueScript(vue);
expect(result).not.toBeNull();
expect(result!.isSetup).toBe(true);
// ts blocks — lang should be empty
expect(result!.lang).toBe('');
});

it('returns null for .vue files with no <script> block', () => {
Expand Down
Loading