mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
8998697f1d
`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>
253 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|