Skip to content

Commit 629d847

Browse files
colbymchenryclaude
andauthored
fix(extraction): index Vue <template> component usages (colbymchenry#629 follow-up) (colbymchenry#659)
Vue's extractor parsed only the <script> block, so a component used solely in another component's <template> (`<MyButton />`) produced no reference — and thus showed a false 0 callers, even after the barrel-resolution fix in PR colbymchenry#657. This is the Vue analogue of Svelte's extractTemplateComponents. extractTemplateComponents() now scans the template (everything outside the <script>/<style> blocks, which also handles nested <template> tags for v-if/slots) for component tags: - PascalCase tags (`<MyButton/>`) — captured as-is. - kebab-case tags (`<my-button/>`) — converted to PascalCase so they match the imported component's name. Safe: an unmatched name creates no edge during resolution, so native custom elements just don't resolve. - Native HTML elements (lowercase, no hyphen) and Vue built-ins (Transition, KeepAlive, …) are skipped. Adds no nodes — only `references` — so node counts stay stable. With this plus colbymchenry#657, a Vue component re-exported through a barrel and used only in a template now resolves end-to-end (callers/impact/callees). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bdfd55e commit 629d847

4 files changed

Lines changed: 150 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2727
- Asking what a symbol impacts no longer drags in every unrelated sibling method of its class — impact now follows real dependencies instead of the structural "contains" relationship, keeping the result focused on what actually depends on the symbol. (#536)
2828
- CodeGraph's MCP server now answers an agent's `resources/list` and `prompts/list` probes with an empty list instead of an error, clearing the `-32601` messages some clients (opencode, Codex) logged on connect. (#621)
2929
- Svelte and Vue components used through a barrel file — `export { default as Button } from './Button.svelte'` re-exported from an `index.ts` and imported elsewhere — are no longer falsely reported as having **0 callers**. CodeGraph now follows the default re-export all the way to the component and resolves the imports that `.svelte` / `.vue` files themselves use, so `codegraph_callers` and `codegraph_impact` see every place a component is used. This also covers components imported from another package in a workspace/monorepo (`@scope/ui/widgets`) and bare directory imports (`import { x } from './'`). Previously a live component consumed only through a barrel looked like dead code. Thanks @nakisen. (#629)
30+
- Components used in a Vue Single-File Component's `<template>``<MyButton />`, or the kebab-case `<my-button />` — are now indexed as usages, so `codegraph_callers` and `codegraph_impact` include components that appear only in another component's markup (including through a barrel re-export). Previously only a Vue component's `<script>` block was analyzed, so template-only usages were invisible. (#629)
3031

3132
## [0.9.9] - 2026-06-02
3233

__tests__/extraction.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3918,6 +3918,32 @@ export default {
39183918
expect(calls).toHaveLength(2);
39193919
});
39203920

3921+
it('should extract component usages from the Vue template (PascalCase + kebab, skipping built-ins) (#629)', () => {
3922+
const code = `<template>
3923+
<div class="wrap">
3924+
<UserCard :user="u" />
3925+
<my-button>Click</my-button>
3926+
<Transition><span>x</span></Transition>
3927+
</div>
3928+
</template>
3929+
3930+
<script setup lang="ts">
3931+
import UserCard from './UserCard.vue';
3932+
import MyButton from './MyButton.vue';
3933+
</script>
3934+
`;
3935+
const result = extractFromSource('Host.vue', code);
3936+
const refs = result.unresolvedReferences
3937+
.filter((r) => r.referenceKind === 'references')
3938+
.map((r) => r.referenceName);
3939+
3940+
expect(refs).toContain('UserCard'); // PascalCase tag
3941+
expect(refs).toContain('MyButton'); // kebab <my-button> → MyButton
3942+
expect(refs).not.toContain('Transition'); // Vue built-in skipped
3943+
expect(refs).not.toContain('Div'); // native HTML element skipped
3944+
expect(refs).not.toContain('Span');
3945+
});
3946+
39213947
it('should extract from both <script> and <script setup> blocks', () => {
39223948
const code = `<template>
39233949
<div>{{ msg }}</div>

__tests__/resolution.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,37 @@ func main() {
13891389
const callers = cg.getCallers(runNode!.id);
13901390
expect(callers.some((c) => c.node.filePath === 'src/App.vue')).toBe(true);
13911391
});
1392+
1393+
it('follows a Vue component used in a <template> through a default re-export barrel (#629)', async () => {
1394+
// End-to-end Vue analogue of the Svelte case: the leaf is a `.vue`
1395+
// component re-exported under an alias (`Thing`) that differs from its
1396+
// real name (`Widget`), and the consumer uses it ONLY in markup
1397+
// (`<Thing />`). Requires both the new template-tag extraction AND the
1398+
// barrel default-export chase to connect the edge.
1399+
fs.mkdirSync(path.join(tempDir, 'src/lib'), { recursive: true });
1400+
fs.writeFileSync(
1401+
path.join(tempDir, 'src/lib/Widget.vue'),
1402+
`<script setup lang="ts">\ndefineProps<{ label?: string }>();\n</script>\n<template><button>x</button></template>\n`
1403+
);
1404+
fs.writeFileSync(
1405+
path.join(tempDir, 'src/lib/index.ts'),
1406+
`export { default as Thing } from './Widget.vue';\n`
1407+
);
1408+
fs.writeFileSync(
1409+
path.join(tempDir, 'src/App.vue'),
1410+
`<script setup lang="ts">\nimport { Thing } from './lib';\n</script>\n<template>\n <Thing />\n</template>\n`
1411+
);
1412+
1413+
cg = await CodeGraph.init(tempDir, { index: true });
1414+
cg.resolveReferences();
1415+
1416+
const widgetNode = cg
1417+
.getNodesByKind('component')
1418+
.find((n) => n.name === 'Widget' && n.filePath === 'src/lib/Widget.vue');
1419+
expect(widgetNode).toBeDefined();
1420+
const callers = cg.getCallers(widgetNode!.id);
1421+
expect(callers.some((c) => c.node.filePath === 'src/App.vue')).toBe(true);
1422+
});
13921423
});
13931424

13941425
describe('C/C++ Import Resolution', () => {

src/extraction/vue-extractor.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,29 @@ import { generateNodeId } from './tree-sitter-helpers';
33
import { TreeSitterExtractor } from './tree-sitter';
44
import { isLanguageSupported } from './grammars';
55

6+
/**
7+
* Vue built-in components — skipped so a `<Transition>` / `<KeepAlive>` in the
8+
* template doesn't become a phantom reference to a user component. Checked
9+
* AFTER kebab→Pascal conversion, so `<keep-alive>` is caught here too.
10+
*/
11+
const VUE_BUILTIN_COMPONENTS = new Set([
12+
'Transition',
13+
'TransitionGroup',
14+
'KeepAlive',
15+
'Suspense',
16+
'Teleport',
17+
'Component',
18+
'Slot',
19+
]);
20+
21+
/** `my-component` → `MyComponent` (Vue allows either form in templates). */
22+
function kebabToPascal(name: string): string {
23+
return name
24+
.split('-')
25+
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : ''))
26+
.join('');
27+
}
28+
629
/**
730
* VueExtractor - Extracts code relationships from Vue Single-File Component files
831
*
@@ -41,6 +64,12 @@ export class VueExtractor {
4164
for (const block of scriptBlocks) {
4265
this.processScriptBlock(block, componentNode.id);
4366
}
67+
68+
// Extract component usages from the <template> (<ComponentName>).
69+
// Without this, a Vue component used only in another component's
70+
// markup (incl. through a barrel import) is invisible to callers /
71+
// impact (#629 follow-up).
72+
this.extractTemplateComponents(componentNode.id);
4473
} catch (error) {
4574
this.errors.push({
4675
message: `Vue extraction error: ${error instanceof Error ? error.message : String(error)}`,
@@ -195,4 +224,67 @@ export class VueExtractor {
195224
this.errors.push(error);
196225
}
197226
}
227+
228+
/**
229+
* Extract component usages from the Vue `<template>`.
230+
*
231+
* PascalCase tags (`<Modal>`, `<Button />`) and kebab-case tags
232+
* (`<my-button>`) both represent component instantiations — analogous to
233+
* function calls in imperative code. Capturing them creates parent→child
234+
* component edges and lets `callers` / `impact` see a component that is
235+
* only ever used in markup. Vue's extractor previously parsed only the
236+
* `<script>` block, so these usages produced no edge at all (#629).
237+
*
238+
* HTML elements (lowercase, no hyphen) and Vue built-ins are skipped.
239+
* Unmatched names create no edge during resolution, so converting
240+
* kebab-case is safe even for native custom elements.
241+
*/
242+
private extractTemplateComponents(componentNodeId: string): void {
243+
// Ranges covered by <script> / <style> blocks — skip them so script
244+
// identifiers and CSS selectors aren't mistaken for template tags. This
245+
// also correctly handles nested <template> tags (v-if / slots), which a
246+
// single non-greedy <template>…</template> match would mis-bound.
247+
const coveredRanges: Array<[number, number]> = [];
248+
const blockRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
249+
let blockMatch;
250+
while ((blockMatch = blockRegex.exec(this.source)) !== null) {
251+
const startLine = (this.source.substring(0, blockMatch.index).match(/\n/g) || []).length;
252+
const endLine = startLine + (blockMatch[0].match(/\n/g) || []).length;
253+
coveredRanges.push([startLine, endLine]);
254+
}
255+
256+
const lines = this.source.split('\n');
257+
// Opening / self-closing tags (closing `</Foo>` starts with `</`, so the
258+
// leading `<` followed by a name letter won't match it).
259+
const tagRegex = /<([A-Za-z][A-Za-z0-9_-]*)\b/g;
260+
261+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
262+
if (coveredRanges.some(([start, end]) => lineIdx >= start && lineIdx <= end)) continue;
263+
264+
const line = lines[lineIdx]!;
265+
let match;
266+
while ((match = tagRegex.exec(line)) !== null) {
267+
const raw = match[1]!;
268+
let componentName: string;
269+
if (/^[A-Z]/.test(raw)) {
270+
componentName = raw; // PascalCase component
271+
} else if (raw.includes('-')) {
272+
componentName = kebabToPascal(raw); // kebab-case component
273+
} else {
274+
continue; // lowercase, no hyphen → native HTML element
275+
}
276+
if (VUE_BUILTIN_COMPONENTS.has(componentName)) continue;
277+
278+
this.unresolvedReferences.push({
279+
fromNodeId: componentNodeId,
280+
referenceName: componentName,
281+
referenceKind: 'references',
282+
line: lineIdx + 1, // 1-indexed
283+
column: match.index + 1,
284+
filePath: this.filePath,
285+
language: 'vue',
286+
});
287+
}
288+
}
289+
}
198290
}

0 commit comments

Comments
 (0)