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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph status` no longer says the index is up to date while indexed files are missing their symbols: it now names files the parser couldn't read and files stored without their symbols (which `codegraph sync` repairs), `status --json` counts both, and `codegraph files --json` lists each file's recorded errors. Thanks @lipchey for the report. (#2336)
- Indexing large Python projects is much faster again and needs less memory: since 1.6.2, resolving Python references re-read source files over and over, so a project the size of CPython took several times as long to index. The graph it builds is unchanged. Thanks @bompus for the report. (#2332)
- In VB.NET, reading or setting a `Shared` field or property through its class or module name, like `AppSession.SessionId`, `AppSession.CurrentUser = "demo"` or `AppSession.Items(0)`, now counts as a use of that member and of the class, and so do reading an `Enum` value like `Mode.Fast` and calling a `Shared` function without parentheses. Before, only calls written with parentheses were linked, so `codegraph callers` on such a field, property or class came back empty and impact missed most of the code that depends on it. A local, parameter or field that only shares a class's name is not mistaken for the class. Thanks @serkanince for the report and @ChrisPrapas. (#2305)
- In Vue and Nuxt components, calls written in the template (`{{ formatDate(post) }}`, `:to="getAccountRoute(account)"`, `@click="save(item)"`) and calls in a destructuring declaration at the top of `<script setup>` or of a TypeScript or JavaScript file (`const { t } = useI18n()`) now link to the function they call, so `callers` and impact for a composable or helper used this way are no longer mostly empty. A `v-for` item or slot prop is never taken for a project function, a handler like `@click="open = false"` no longer links to an unrelated function of the same name, and a component's own function named like a Nuxt helper, such as `clearError`, is now the one its calls reach. Thanks @deniskern for the report, and @drakeo338 and @L0garithmic. (#2340)

## [1.6.2] - 2026-10-03

Expand Down
65 changes: 65 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,44 @@ export const exported = { handler: () => target() };
]);
});
});

// A destructuring declaration mints no symbol, so its initializer was never
// walked at module scope: `const { a } = useFoo(1)` recorded no call at all,
// where the same line inside a function did (#2340).
it.each(['ts', 'tsx', 'js', 'jsx'])('records the calls of a module-scope destructuring declaration (%s, #2340)', (ext) => {
const code = [
'const { a } = useFoo(1)',
'let [b, c] = pair()',
'var { d: { e } } = nested()',
'const { f = fallback() } = withDefault()',
'const [g = other(), ...rest] = list()',
'export const { h } = exported()',
'const { i } = await load(() => inArrow())',
'export function probe() { const { j } = inner(); return j }',
'',
].join('\n');
const result = extractFromSource(`app.${ext}`, code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => `${byId.get(r.fromNodeId)?.kind}:${r.referenceName}@${r.line}`)
.sort();
expect(calls).toEqual([
'file:exported@6',
'file:fallback@4',
'file:inArrow@7',
'file:list@5',
'file:load@7',
'file:nested@3',
'file:other@5',
'file:pair@2',
'file:useFoo@1',
'file:withDefault@4',
'function:inner@8', // inside a function: the function's, as before
]);
// Still no symbol for a destructured binding.
expect(result.nodes.filter((n) => n.kind === 'constant' || n.kind === 'variable')).toEqual([]);
});
});

describe('File Node Extraction', () => {
Expand Down Expand Up @@ -9165,6 +9203,33 @@ const token = getTokenMp();
expect(call).toBeDefined();
});

it.each(['LF', 'CRLF'])('should attribute calls in <script setup> destructuring and in the template to the component (%s, #2340)', (ending) => {
const lf = `<template>
<NuxtLink :to="useBar(link.location)">{{ useBar(link) }}</NuxtLink>
<li v-for="item in items" @click="item.open()">{{ label(item) }}</li>
</template>

<script setup lang="ts">
const { a } = useFoo(1)
const [b] = useFoo(2)
</script>
`;
const code = ending === 'CRLF' ? lf.replace(/\n/g, '\r\n') : lf;
const result = extractFromSource('Card.vue', code);
const component = result.nodes.find((n) => n.kind === 'component')!;
const calls = result.unresolvedReferences
.filter((r) => r.referenceKind === 'calls')
.map((r) => `${r.fromNodeId === component.id ? 'component' : r.fromNodeId}:${r.referenceName}@${r.line}`)
.sort();
expect(calls).toEqual([
'component:label@3',
'component:useBar@2',
'component:useBar@2',
'component:useFoo@7',
'component:useFoo@8',
]);
});

it('should extract calls from Vue Options API object methods', () => {
const code = `<template>
<button @click="save">Save</button>
Expand Down
24 changes: 24 additions & 0 deletions __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,30 @@ async function exprReceivers(x, y) {
]);
});

it.each([
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
] as const)('walks a module-scope destructuring declaration like a body does: %s (#2340)', (ext, language) => {
const typed = language === 'typescript' || language === 'tsx';
const result = assertParity(`fixture.${ext}`, `
import { handler } from './h';
const { a } = useFoo(1);
let [b, c] = pair();
var { d: { e } } = nested();
const { f = fallback() } = withDefault(handler);
const [g = other(), ...rest] = list(() => inArrow(handler));
export const { h } = exported(new Store());
export const { useGetUserQuery } = api;
${typed ? 'const { k }: Shape = make();' : ''}
function probe() { const { j } = inner(); return j; }
export function second() { return probe(); }
`, language);
const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
expect(calls).toEqual(expect.arrayContaining([
'useFoo', 'pair', 'nested', 'fallback', 'withDefault', 'other', 'list', 'inArrow', 'exported', 'inner',
...(typed ? ['make'] : []),
]));
});

it('torture fixture (tsx): components, stores, RTK, fn-refs, value-refs, decorators', () => {
const file = path.join(FIXTURE_DIR, 'torture.tsx');
assertParity('fixtures/torture.tsx', fs.readFileSync(file, 'utf8'), 'tsx');
Expand Down
252 changes: 252 additions & 0 deletions __tests__/vue-template-calls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/**
* 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');
});
});
7 changes: 6 additions & 1 deletion codegraph-kernel/src/tsjs/extractors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,14 +387,19 @@ impl<'t> Walker<'t> {
let Some(name_node) = child.child_by_field_name("name") else { continue };
let value = child.child_by_field_name("value");

// Destructured patterns are skipped — except RTK Query generated
// Destructured patterns mint no node — except RTK Query generated
// hooks (`export const { useGetXQuery } = api`).
if matches!(name_node.kind(), "object_pattern" | "array_pattern") {
if name_node.kind() == "object_pattern"
&& value.map(|v| v.kind() == "identifier").unwrap_or(false)
{
self.extract_rtk_hook_bindings(name_node, is_exported);
}
// The initializer (and any default in the pattern) still runs:
// walk the declarator as a function body walks it, with the
// enclosing scope on the stack (#2340). Mirrors
// TreeSitterExtractor.extractVariable.
self.visit_function_body(child);
continue;
}
let name = self.text(name_node).to_string();
Expand Down
Loading