Files
codegraph/__tests__/vue-template-calls.test.ts
Colby Mchenry 8998697f1d fix(vue,ts): link calls in Vue templates and in top-level destructuring (#2340) (#2357)
`codegraph callers <composable>` missed most of the components using it in
a Nuxt app (Issue #2340), through two extraction gaps:

- A destructuring declaration at module or `<script setup>` scope
  (`const { a } = useFoo(1)`, `const [b] = useFoo(1)`) mints no symbol,
  and extractVariable skipped its declarator with `continue` before
  walking the initializer, so the call was never recorded (inside a
  function the body walker reached it). The declarator is now walked the
  way a function body walks it, with the enclosing scope on the stack:
  the file for module code, which a `<script setup>` block folds into its
  component. Defaults in the pattern and calls inside callbacks in the
  initializer count too. Mirrored in the native kernel
  (tsjs/extractors.rs), which extracts TS/JS in released builds.
- The Vue extractor parsed only `<script>`, so calls in the template
  (`{{ useBar(link) }}`, `:to="useBar(link.location)"`, `v-if`, `v-for`
  sources, `@click="save(item)"`) made no reference. The new
  vue-template-calls scanner reads the root `<template>` quote-aware,
  collects interpolations and directive values, and names each callee
  the way a script call is named (`useBar`, `store.fetchUsers`). It skips
  `v-for` aliases and slot props in their element's subtree, arrow and
  function parameters, `$`-helpers, Vue's template globals, strings,
  regex literals, `new X()`, object-method shorthand, `v-pre` subtrees,
  custom blocks and non-HTML templates. Each call is a `calls` reference
  from the component at its own line and column; a bare call to the
  component's own Options API method is linked at extraction, since
  resolution only binds those through `this.`.

Two resolution follow-ons, found validating on elk:

- The vue-handler synthesizer resolved the leading identifier of every
  `@event` value, so `@click="save(item)"` got a second, line-1 edge, and
  `@click="query = ''"`, a slot prop's `hide()` or a `refresh()`
  destructured from useAsyncData were bound to any same-named function in
  another file. It now resolves only a bare handler name (`@click="save"`)
  and keeps its composable-destructure mapping.
- The Nuxt auto-import rule answered a call to a file's own `clearError`
  or `navigateTo` with an edge from the caller to itself. A same-file
  declaration of that name now shadows the auto-import.

Verification: new tests in vue-template-calls.test.ts (scanner shapes and
four indexed projects, incl. the issue's repro), extraction.test.ts
(module-scope destructuring in ts/tsx/js/jsx; script-setup + template
refs, LF and CRLF) and kernel-tsjs-parity.test.ts (destructuring, kernel
vs wasm). On main (src + kernel reverted) the 10 indexed/extraction tests
fail and the 4 parity cases fail with the main kernel; all pass with the
fix (CODEGRAPH_KERNEL_EXPECT=1). Issue repro on the built CLI with the kernel:
callers useFoo 2 -> 5 (A.vue, B.vue, F.vue, G.ts, D.ts), callers useBar
0 -> 2 (H.vue, I.vue). kernel-parity.mjs over elk, nuxt/movies and
fastify: 467/471 files byte-identical, 0 diffs, 4 deferred.

Validation (kernel loaded, main vs branch, edges by natural key):
elk 6282 -> 6633 (+443 / -92), vue-element-admin 2337 -> 2372 (+60 /
-25), nuxt/movies 939 -> 959 (+26 / -6), fastify (plain JS control) no
edge change. Every removed edge is a line-1 vue-handler edge whose pair
is now linked at the handler's line (101), a name-collision guess (8),
or an auto-import self-loop now resolved to the local function or route
(14). Self-loops: elk 44 -> 30, others unchanged.

Builds on #2341 by @drakeo338 (walk the destructuring initializer) and
the template scan in #2073 by @L0garithmic.

Co-authored-by: drakeo338 <paranoyouz@gmail.com>
Co-authored-by: L0garithmic <L0garithmic@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:17:51 +00:00

253 lines
10 KiB
TypeScript

/**
* A call is a call wherever a Vue component writes it (#2340): in the
* `<template>` (`{{ useBar(link) }}`, `:to="useBar(link.location)"`,
* `@click="save(item)"`), and in a destructuring declaration at module or
* `<script setup>` scope (`const { a } = useFoo(1)`). Neither used to record
* one, so `callers` on a Nuxt composable missed most of the components using
* it. (`src/extraction/vue-template-calls.ts`, wired in `vue-extractor.ts`;
* the destructuring walk is in `tree-sitter.ts` extractVariable and the
* kernel's `tsjs/extractors.rs`.)
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { vueTemplateCalls } from '../src/extraction/vue-template-calls';
const names = (source: string) => vueTemplateCalls(source).map((c) => c.name);
describe('vueTemplateCalls', () => {
it('finds calls in interpolations, bindings, directives and handlers, at their offsets', () => {
const source = `<template>
<NuxtLink :to="useBar(link.location)" v-if="canShow(link)">{{ useBar(link) }}</NuxtLink>
<li v-for="row in rowsFor(user)" v-show="visible()" v-my-dir="setup(1)">{{ label(row) }}</li>
<button @click="save(item)" v-on:keyup.enter="submit()" @input="onInput">go</button>
<Comp :style="{ color: tint() }" :title="\`#\${index(n)}\`" @change="(v) => update(v)" />
</template>
`;
const calls = vueTemplateCalls(source);
expect(calls.map((c) => c.name)).toEqual([
'useBar', 'canShow', 'useBar',
'rowsFor', 'visible', 'setup', 'label',
'save', 'submit',
'tint', 'index', 'update',
]);
// Offsets point at the callee in the SFC source.
for (const c of calls) expect(source.slice(c.offset, c.offset + c.name.length)).toBe(c.name);
});
it('names a member call by its path, as a script call is named', () => {
expect(names(`<template>
<p @click="store.fetchUsers()" :a="user?.profile?.load()" :b="fn?.(1)">{{ props.format(v) }}</p>
<p :c="list[0].go()" :d="make().then(x => x.done())">{{ (a as B).run() }}</p>
</template>`)).toEqual(['store.fetchUsers', 'user.profile.load', 'fn', 'props.format', 'make']);
});
it('skips names the template binds: v-for aliases and slot props in their subtree, arrow and function parameters', () => {
expect(names(`<template>
<ul>
<li v-for="(item, i) in items" :key="item.id" @click="item.select(i)">
{{ item.label() }} {{ format(item) }}
<Row #default="{ row, toggle }">{{ toggle(row) }} {{ rowLabel(row) }}</Row>
</li>
</ul>
<Comp :cb="function (x) { return x.go() }" :keys="(k) => k.trim()" @done="cb => cb()" />
<p>{{ toggle() }} {{ item() }}</p>
</template>`)).toEqual(['format', 'rowLabel', 'toggle', 'item']);
});
it('skips what is never project code: strings, Vue template globals, $-helpers, keywords, new, object methods', () => {
expect(names(`<template>
<p :title="$t('x')" :class="'a(b)'">{{ Math.max(a(), 1) }} {{ JSON.stringify(c) }} {{ $route.query.x }}</p>
<p v-if="typeof x === 'function' && /f(o)/.test(s)">{{ new Date(stamp()).getTime() }} {{ "no()" }}</p>
<Chart :options="{ formatter(v) { return fmt(v) } }" @click="$emit('close')" />
</template>`)).toEqual(['a', 'stamp', 'fmt']);
});
it('reads a CRLF file the same way', () => {
const lf = '<template>\n <p\n :title="label(\n item)"\n >{{ useBar(x) }}</p>\n</template>\n';
const crlf = lf.replace(/\n/g, '\r\n');
expect(names(crlf)).toEqual(['label', 'useBar']);
for (const c of vueTemplateCalls(crlf)) expect(crlf.slice(c.offset, c.offset + c.name.length)).toBe(c.name);
});
it('reads only the root template, and only what Vue compiles', () => {
expect(names(`<template>
<!-- {{ commented() }} -->
<div v-pre>{{ raw() }} <span :x="raw()"></span></div>
<template v-if="cond()"><span>{{ inner() }}</span></template>
<p data-x="plain(1)">{{ after() }}</p>
</template>
<script setup>
const x = scriptCall()
</script>
<i18n>{ "en": { "a": "{{ notTemplate() }}" } }</i18n>`)).toEqual(['cond', 'inner', 'after']);
expect(names('<template lang="pug">\ndiv {{ pug() }}\n</template>')).toEqual([]);
expect(names('<script setup>\nconst a = b()\n</script>')).toEqual([]);
});
});
describe('calls written in Vue components, indexed (#2340)', () => {
let root = '';
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = '';
});
function write(files: Record<string, string>): void {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-calls-'));
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
}
function callersOf(name: string, file: string): string[] {
const target = cg!.getNodesInFile(file).find((n) => n.name === name && (n.kind === 'function' || n.kind === 'method'));
expect(target, `${name} in ${file}`).toBeDefined();
return cg!.getCallers(target!.id).map(({ node }) => `${node.filePath}:${node.kind}`).sort();
}
it("finds every caller in the issue's repro", async () => {
write({
'package.json': JSON.stringify({ name: 'nuxt-app', dependencies: { nuxt: '^4.0.0' } }),
'composables/useFoo.ts': 'export function useFoo(n: number) { return { a: n }; }\n',
'composables/useBar.ts': 'export function useBar(x: unknown) { return String(x); }\n',
'components/A.vue': '<script setup lang="ts">\nconst { a } = useFoo(1)\n</script>\n',
'components/F.vue': '<script setup lang="ts">\nconst [b] = useFoo(1)\n</script>\n',
'components/B.vue': '<script setup lang="ts">\nconst x = useFoo(1)\n</script>\n',
'components/H.vue': '<template><div>{{ useBar(link) }}</div></template>\n',
'components/I.vue': '<template><NuxtLink :to="useBar(link.location)">x</NuxtLink></template>\n',
'G.ts': 'const { a } = useFoo(1)\nexport const g = a\n',
'D.ts': 'export function probe() { const { a } = useFoo(1); return a }\n',
});
cg = await CodeGraph.init(root, { index: true });
expect(callersOf('useFoo', 'composables/useFoo.ts')).toEqual([
'D.ts:function', // inside a function: already found
'G.ts:file', // module scope: the file is the caller
'components/A.vue:component',
'components/B.vue:component',
'components/F.vue:component',
]);
expect(callersOf('useBar', 'composables/useBar.ts')).toEqual([
'components/H.vue:component',
'components/I.vue:component',
]);
});
it('links a template call to an imported helper, a script-setup function and an Options API method', async () => {
write({
'utils/format.ts': 'export function formatPrice(n: number) { return `$${n}`; }\n',
'other/format.ts': 'export function formatPrice(n: number) { return n; }\n',
'components/Setup.vue': `<template>
<p>{{ formatPrice(item.price) }}</p>
<button @click="save(item)">save</button>
</template>
<script setup lang="ts">
import { formatPrice } from '../utils/format';
const props = defineProps<{ item: { price: number } }>();
function save(item: unknown) { return item; }
</script>
`,
'components/Options.vue': `<template>
<p>{{ total(items) }}</p>
<button @click="submit(form)">send</button>
</template>
<script>
export default {
methods: {
total(items) { return items.length },
submit(form) { return form }
}
}
</script>
`,
});
cg = await CodeGraph.init(root, { index: true });
const edgesFrom = (file: string) => {
const comp = cg!.getNodesInFile(file).find((n) => n.kind === 'component')!;
return cg!.getOutgoingEdges(comp.id)
.filter((e) => e.kind === 'calls')
.map((e) => {
const t = cg!.getNode(e.target)!;
return `${t.name}@${t.filePath}:${e.line}`;
})
.sort();
};
// The import decides which `formatPrice`; each edge is on its template line.
expect(edgesFrom('components/Setup.vue')).toEqual([
'formatPrice@utils/format.ts:2',
'save@components/Setup.vue:3',
]);
// A template calls the component's own methods without `this.`.
expect(edgesFrom('components/Options.vue')).toEqual([
'submit@components/Options.vue:3',
'total@components/Options.vue:2',
]);
});
it("calls a component's own function even where Nuxt auto-imports one of that name", async () => {
write({
'package.json': JSON.stringify({ name: 'nuxt-app', dependencies: { nuxt: '^4.0.0' } }),
'pages/lists.vue': `<template>
<button @click="clearError(true)">x</button>
</template>
<script setup lang="ts">
function clearError(focus: boolean) { return focus; }
onDeactivated(() => clearError(false));
</script>
`,
});
cg = await CodeGraph.init(root, { index: true });
const nodes = cg.getNodesInFile('pages/lists.vue');
const comp = nodes.find((n) => n.kind === 'component')!;
const own = nodes.find((n) => n.kind === 'function' && n.name === 'clearError')!;
const calls = cg.getOutgoingEdges(comp.id).filter((e) => e.kind === 'calls');
// Both calls are the local function's — not Nuxt's `clearError`, which
// the framework rule used to answer with an edge from the component to itself.
expect(calls.filter((e) => e.target === own.id).map((e) => e.line).sort()).toEqual([2, 7]);
expect(calls.filter((e) => e.target === comp.id)).toEqual([]);
});
it('keeps a bare handler linked, and binds a handler expression only through its own call', async () => {
write({
'lib/bus.ts': 'export class Bus {\n emit(event: string) { return event; }\n}\n',
'components/Dialog.vue': `<template>
<button @click="close">x</button>
<button @click="emit('cancel')">cancel</button>
<button @click="open = !open">toggle</button>
</template>
<script setup lang="ts">
const emit = defineEmits(['cancel']);
let open = false;
function close() { open = false; }
</script>
`,
});
cg = await CodeGraph.init(root, { index: true });
const comp = cg.getNodesInFile('components/Dialog.vue').find((n) => n.kind === 'component')!;
const targets = cg.getOutgoingEdges(comp.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg!.getNode(e.target)!)
.map((n) => `${n.kind}:${n.name}`);
// `@click="close"` is still the handler it names.
expect(targets).toContain('function:close');
// `emit('cancel')` is the component's `defineEmits` binding, and `open =
// !open` calls nothing: neither reaches Bus.emit.
expect(targets).not.toContain('method:emit');
});
});