fix(vue): Options API methods, computed, watchers and hooks are symbols (#2140)

A `<script>` block goes through the TS/JS extractor, which extracts no
symbol for a method in an object literal — so every Vue 2 component, and
every Vue 3 one not on `<script setup>`, had no `handleLogin`, no
`mounted`: their calls, their `this.$router.push`, and the template's
`@click="handleLogin"` all belonged to the file.

`vue-options-api.ts` reads the options object the default export is
(directly, or via defineComponent / Vue.extend / Vue.component) and names
each function in it — `methods`, `computed` (incl. get/set objects),
`watch` (incl. `{ handler }`), `data`, `setup`, lifecycle hooks, Nuxt 2's
asyncData/fetch — as `method` nodes owned by the component; the Vue
extractor re-attributes the references and edges written inside each.

A component's own method is only reached as `this.m()` in that component:
the member-call and exact-name strategies no longer hand it
`e.preventDefault()` (ImageCropper's `preventDefault` method took every
event's call), `this.editor.setValue()`, or `this.$refs['input'].click()`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 08:22:09 +00:00
committed by GitHub
parent 66d5dc3641
commit a3d675ef4d
5 changed files with 411 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Vue components written with the Options API (`export default { methods: {…}, computed: {…}, watch: {…}, mounted() {…} }`), as every Vue 2 app is, now have their methods, computed properties, watchers and lifecycle hooks in the graph. Each one has its own callers and calls, and a template's `@click="handleLogin"` links to the method it runs. On vue-element-admin that's over 800 new links, where a component's calls used to belong to the whole file.
- In Java, Kotlin, Swift, C#, C and C++, Go, Scala, Ruby, Dart and the other case-sensitive languages, a name no longer links to a symbol whose name differs only in case. On jsoup, JUnit's `@Test` had linked every test to a `test` method; `new CookieManager()` had linked to a `cookieManager()` getter, and a `Method` type to a `method()`. PHP, Pascal, CFML, COBOL and VB.NET, whose names really do ignore case, are unchanged.
- React Native events whose name is held in a constant now link native code to the JavaScript that listens for them, like NetInfo's `addListener(PrivateTypes.DEVICE_CONNECTIVITY_EVENT, …)` or a Java `.emit(PROGRESS_EVENT, …)`. The constant is read the way the language scopes it, from the same file, an import or its owning class, so a parameter that happens to share a constant's name is never mistaken for it.
- React Native views declared with `requireNativeComponent('X')`, the older Paper style that react-native-maps and segmented-control use, now link from JavaScript to their native iOS and Android implementations, as Codegen specs already did, including in plain `.js` files. A JSX tag written under a different name than its component, like `<Autocomplete>` for `import Autocomplete from './autocomplete.vue'`, now links to the component it renders.
+115
View File
@@ -0,0 +1,115 @@
/**
* A Vue Options API component's functions are symbols: `methods`, `computed`,
* `watch` entries and the lifecycle hooks, each owning the calls written in it.
* (`src/extraction/vue-options-api.ts`, wired in `vue-extractor.ts`.)
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { vueOptionsMembers } from '../src/extraction/vue-options-api';
describe('vueOptionsMembers', () => {
it('names every function an options object declares', () => {
const script = `
import { mapActions } from 'vuex'
export default {
name: 'Login',
props: { redirect: String },
data() {
return { loading: false }
},
computed: {
title() { return 'Sign in' },
fullName: {
get() { return this.first },
set(v) { this.first = v }
},
...mapGetters(['user'])
},
watch: {
$route: { handler(route) { this.redirect = route.query.redirect }, immediate: true },
'form.email'(value) { this.check(value) },
loading: 'onLoading'
},
created() { this.init() },
mounted: function () { this.focus() },
methods: {
...mapActions(['login']),
async handleLogin() { await this.login() },
validate: (value) => value.length > 0,
reset: function () {},
[DYNAMIC]() {}
}
}
`;
expect(vueOptionsMembers(script).map((m) => m.name)).toEqual([
'data', 'title', 'fullName', '$route', 'form.email', 'created', 'mounted', 'handleLogin', 'validate', 'reset',
]);
});
it('reads defineComponent, Vue.extend and Vue.component, and nothing without an options object', () => {
expect(vueOptionsMembers(`export default defineComponent({ setup() { return {} } })`).map((m) => m.name)).toEqual(['setup']);
expect(vueOptionsMembers(`export default Vue.extend({ methods: { go() {} } })`).map((m) => m.name)).toEqual(['go']);
expect(vueOptionsMembers(`export default Vue.component('x-btn', { methods: { tap() {} } })`).map((m) => m.name)).toEqual(['tap']);
expect(vueOptionsMembers(`const x = { methods: { go() {} } }\nexport default x`)).toEqual([]);
});
});
describe('a Vue Options API component, indexed', () => {
let root = '';
afterAll(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
});
it('gives each method its calls, and the template handler its method', async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vue-options-'));
fs.mkdirSync(path.join(root, 'src/views/login'), { recursive: true });
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'admin', dependencies: { vue: '^2.6.0' } }));
fs.writeFileSync(
path.join(root, 'src/views/login/index.vue'),
`<template>
<form>
<button @click="handleLogin">Sign in</button>
</form>
</template>
<script>
export default {
name: 'Login',
data() {
return { loading: false }
},
methods: {
handleLogin() {
if (this.validate()) {
this.loading = true
}
},
validate() {
return true
}
}
}
</script>
`
);
const cg = await CodeGraph.init(root, { index: true });
try {
const nodes = cg.getNodesInFile('src/views/login/index.vue');
const method = (name: string) => nodes.find((n) => n.kind === 'method' && n.name === name)!;
expect(nodes.filter((n) => n.kind === 'method').map((n) => n.name).sort()).toEqual(['data', 'handleLogin', 'validate']);
expect(method('handleLogin').qualifiedName).toBe('index::handleLogin');
// The call inside handleLogin is handleLogin's, not the file's.
const calls = cg.getOutgoingEdges(method('handleLogin').id).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.name);
expect(calls).toContain('validate');
// `@click="handleLogin"` in the template runs the method.
const component = nodes.find((n) => n.kind === 'component')!;
const handlers = cg.getOutgoingEdges(component.id).filter((e) => e.target === method('handleLogin').id);
expect(handlers.length).toBeGreaterThan(0);
} finally {
cg.close();
}
});
});
+67
View File
@@ -2,6 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { vueOptionsMembers } from './vue-options-api';
/**
* Vue built-in components — skipped so a `<Transition>` / `<KeepAlive>` in the
@@ -114,6 +115,67 @@ export class VueExtractor {
return node;
}
/**
* Method nodes for an Options API component's members (see
* ./vue-options-api), and the references and edges written inside each —
* which the TS extractor attributed to the file — re-attributed to it.
* Lines are block-relative here; the caller offsets them with the rest.
*/
private addOptionsMembers(
block: { content: string; startLine: number },
result: ExtractionResult,
componentNodeId: string
): void {
const members = vueOptionsMembers(block.content);
if (members.length === 0) return;
const component = this.nodes.find((n) => n.id === componentNodeId);
const owner = component?.name ?? 'component';
const lineAt = (offset: number) => block.content.slice(0, offset).split('\n').length;
const colAt = (offset: number) => offset - block.content.lastIndexOf('\n', offset - 1) - 1;
const now = Date.now();
const created: Node[] = [];
for (const m of members) {
const startLine = lineAt(m.start);
const endLine = lineAt(m.end);
created.push({
id: generateNodeId(this.filePath, 'method', `${owner}.${m.name}`, startLine + block.startLine),
kind: 'method',
name: m.name,
qualifiedName: `${owner}::${m.name}`,
filePath: this.filePath,
language: 'vue',
startLine,
endLine,
startColumn: colAt(m.start),
endColumn: colAt(m.end),
updatedAt: now,
});
}
// Innermost member for a line: `computed: { x: { get() {…} } }` is one member.
const memberAt = (line: number): Node | undefined => {
let best: Node | undefined;
for (const n of created) {
if (n.startLine <= line && n.endLine >= line && (!best || n.startLine >= best.startLine)) best = n;
}
return best;
};
// What the TS extractor attributed to the file (or to nothing narrower).
const fileNode = result.nodes.find((n) => n.kind === 'file');
const narrower = new Set(result.nodes.filter((n) => n.kind !== 'file').map((n) => n.id));
const isFileLevel = (id: string) => (fileNode ? id === fileNode.id : !narrower.has(id));
for (const ref of result.unresolvedReferences) {
if (!isFileLevel(ref.fromNodeId)) continue;
const member = memberAt(ref.line);
if (member) ref.fromNodeId = member.id;
}
for (const edge of result.edges) {
if (edge.kind === 'contains' || !edge.line || !isFileLevel(edge.source)) continue;
const member = memberAt(edge.line);
if (member) edge.source = member.id;
}
result.nodes.push(...created);
}
/**
* Extract <script> and <script setup> blocks from the Vue source
*/
@@ -187,6 +249,11 @@ export class VueExtractor {
const extractor = new TreeSitterExtractor(this.filePath, block.content, scriptLanguage);
const result = extractor.extract();
// An Options API component's functions — `methods`, `computed`, `watch`,
// lifecycle hooks — are object-literal members the TS extractor leaves as
// part of the file. Name each one, and hand it the calls written inside it.
if (!block.isSetup) this.addOptionsMembers(block, result, componentNodeId);
// Offset line numbers from script block back to .vue file positions
for (const node of result.nodes) {
node.startLine += block.startLine;
+204
View File
@@ -0,0 +1,204 @@
/**
* The members of a Vue Options API component — `export default { methods:
* { login() {…} }, computed: {…}, watch: {…}, mounted() {…} }`.
*
* A `<script>` block goes through the TypeScript/JavaScript extractor, which
* extracts no symbol for a method written in an object literal. Every Vue 2
* app, and every Vue 3 app not on `<script setup>`, keeps its code there:
* vue-element-admin's login screen had no `handleLogin`, so its calls, its
* `this.$router.push`, and the `@click="handleLogin"` that runs it all
* belonged to the file.
*
* This reads the options object the default export is — directly, or through
* `defineComponent({…})` / `Vue.extend({…})` / `Vue.component('x', {…})` —
* and names each function in it: `methods`, `computed` and `watch` entries,
* and the component's own functions (`data`, `setup`, the lifecycle hooks,
* Nuxt 2's `asyncData` / `fetch`, `render`).
*/
/** One function-valued member of the options object, as offsets into the script. */
export interface OptionsMember {
name: string;
/** Where the member's key starts. */
start: number;
/** One past where its value ends. */
end: number;
}
/** The options a component declares as functions of its own. */
const COMPONENT_FUNCTIONS = new Set([
'data', 'setup', 'render',
'beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated',
'activated', 'deactivated', 'beforeDestroy', 'destroyed', 'beforeUnmount', 'unmounted',
'errorCaptured', 'renderTracked', 'renderTriggered', 'serverPrefetch',
// Nuxt 2
'asyncData', 'fetch', 'head', 'validate', 'middleware',
]);
/** The groups whose every entry is a function (or, for computed/watch, an object of them). */
const MEMBER_GROUPS = new Set(['methods', 'computed', 'watch']);
/** The index after a string, template or regex-free literal starting at `at`; -1 if unterminated. */
function skipQuoted(s: string, at: number): number {
const quote = s[at]!;
for (let i = at + 1; i < s.length; i++) {
const ch = s[i]!;
if (ch === '\\') {
i++;
continue;
}
if (quote === '`' && ch === '$' && s[i + 1] === '{') {
const close = matchClose(s, i + 1);
if (close < 0) return -1;
i = close;
continue;
}
if (ch === quote) return i + 1;
}
return -1;
}
/** The index of the bracket closing the one at `open`; -1 if unbalanced. */
function matchClose(s: string, open: number): number {
let depth = 0;
for (let i = open; i < s.length; i++) {
const ch = s[i]!;
if (ch === '"' || ch === "'" || ch === '`') {
const end = skipQuoted(s, i);
if (end < 0) return -1;
i = end - 1;
continue;
}
if (ch === '/' && s[i + 1] === '/') {
const nl = s.indexOf('\n', i);
i = nl < 0 ? s.length : nl;
continue;
}
if (ch === '/' && s[i + 1] === '*') {
const close = s.indexOf('*/', i + 2);
i = close < 0 ? s.length : close + 1;
continue;
}
if (ch === '{' || ch === '[' || ch === '(') depth++;
else if (ch === '}' || ch === ']' || ch === ')') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
interface RawMember {
name: string;
start: number;
/** Where the value starts (after `:`), or the `(` of a method shorthand. */
valueAt: number;
end: number;
shorthand: boolean;
}
/** The members of the object literal whose `{` is at `open`. */
function objectMembers(s: string, open: number): RawMember[] {
const close = matchClose(s, open);
if (close < 0) return [];
const out: RawMember[] = [];
let i = open + 1;
while (i < close) {
// Skip separators, whitespace and comments between members.
const ws = /^(?:\s|,|\/\/[^\n]*|\/\*[\s\S]*?\*\/)+/.exec(s.slice(i, close));
if (ws) {
i += ws[0].length;
continue;
}
const start = i;
// `async name(…)`, `*name(…)`, `get name()`, `'quoted-key'`, `name`, `...spread`
const head = /^(?:(?:async|get|set)\s+(?=[\w$'"[]))?\*?\s*(?:([A-Za-z_$][\w$]*)|(['"])((?:(?!\2).)*)\2|(\[)|(\.\.\.))/.exec(s.slice(i, close));
if (!head) {
i++;
continue;
}
const name = head[1] ?? head[3] ?? null;
let j = i + head[0].length;
if (head[5] !== undefined) {
// A computed key — `[KEY]() {}` — has no static name; step over it.
const keyClose = matchClose(s, j - 1);
if (keyClose < 0) return out;
j = keyClose + 1;
}
// Where this member ends: the next depth-0 comma, or the object's end.
let k = j;
while (k < close) {
const ch = s[k]!;
if (ch === '"' || ch === "'" || ch === '`') {
const end = skipQuoted(s, k);
if (end < 0) return out;
k = end;
continue;
}
if (ch === '/' && (s[k + 1] === '/' || s[k + 1] === '*')) {
const end = s[k + 1] === '/' ? s.indexOf('\n', k) : s.indexOf('*/', k + 2) + 1;
k = end <= 0 ? close : end + 1;
continue;
}
if (ch === '{' || ch === '[' || ch === '(') {
const end = matchClose(s, k);
if (end < 0) return out;
k = end + 1;
continue;
}
if (ch === ',') break;
k++;
}
const rest = s.slice(j, k);
const colon = /^\s*:/.exec(rest);
if (name !== null && head[6] === undefined) {
out.push({
name,
start,
valueAt: colon ? j + colon[0].length : j,
end: k,
shorthand: !colon && /^\s*\(/.test(rest),
});
}
i = k + 1;
}
return out;
}
/** Whether a member's value is a function: a shorthand method, `function (…) {…}`, or an arrow. */
function isFunctionValue(s: string, m: RawMember): boolean {
if (m.shorthand) return true;
const value = s.slice(m.valueAt, m.end).trimStart();
return /^(?:async\s+)?function\b/.test(value) || /^(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.test(value);
}
/** The `{` of the options object the default export is, or -1. */
function optionsObjectStart(script: string): number {
const m = /\bexport\s+default\s+(?:(?:defineComponent|defineNuxtComponent|Vue\s*\.\s*extend)\s*\(\s*|Vue\s*\.\s*component\s*\(\s*(['"])[^'"]*\1\s*,\s*)?\{/.exec(script);
return m ? m.index + m[0].length - 1 : -1;
}
/** Every function a Vue Options API component declares, in source order. */
export function vueOptionsMembers(script: string): OptionsMember[] {
const open = optionsObjectStart(script);
if (open < 0) return [];
const out: OptionsMember[] = [];
for (const member of objectMembers(script, open)) {
if (COMPONENT_FUNCTIONS.has(member.name) && isFunctionValue(script, member)) {
out.push({ name: member.name, start: member.start, end: member.end });
continue;
}
if (!MEMBER_GROUPS.has(member.name) || member.shorthand) continue;
const valueOpen = script.indexOf('{', member.valueAt);
if (valueOpen < 0 || valueOpen >= member.end || script.slice(member.valueAt, valueOpen).trim() !== '') continue;
for (const entry of objectMembers(script, valueOpen)) {
// `computed: { x: { get() {}, set() {} } }` and `watch: { y: { handler() {} } }`
// are functions too; a watcher written as a method NAME (`z: 'onZ'`) is not.
const value = script.slice(entry.valueAt, entry.end).trimStart();
if (isFunctionValue(script, entry) || (member.name !== 'methods' && value.startsWith('{'))) {
out.push({ name: entry.name, start: entry.start, end: entry.end });
}
}
}
return out;
}
+24
View File
@@ -1566,6 +1566,9 @@ export function matchByExactName(
!((bareJs || bareGo) && TYPE_MEMBER_KINDS.has(n.kind)) &&
// A bare PHP call is a function call: nothing else is callable without a receiver.
!(barePhp && n.kind !== 'function') &&
// A Vue component's own method is `this.m()` inside that component — not
// `this.$refs['input'].click()` on an element another component renders.
!(ref.referenceKind === 'calls' && JS_FAMILY.has(ref.language) && isVueComponentMethod(n) && !isThisCallInOwnFile(n, ref, context)) &&
// An `extends`/`implements` ref names a supertype, so anything that can't
// BE one is not a candidate at all. This is eligibility, not
// ranking: kind is only a scoring bonus below (and none is awarded for
@@ -3620,6 +3623,15 @@ export function matchMethodCall(
narrowed = kept.length !== targetMethods.length;
targetMethods = kept;
}
// A Vue component's own method is reached as `this.m()` inside it —
// never as `e.preventDefault()` on an event, nor `this.editor.setValue()`
// on something the component holds. A template ref
// (`this.$refs.form.validate()`) names a child this cannot tell apart.
if (JS_FAMILY.has(ref.language)) {
const kept = targetMethods.filter((m) => !isVueComponentMethod(m) || (objectOrClass === 'this' && m.filePath === ref.filePath));
narrowed ||= kept.length !== targetMethods.length;
targetMethods = kept;
}
// If only one same-language method with this name exists, use it
if (targetMethods.length === 1 && !narrowed && targetMethods[0]!.language === ref.language) {
@@ -3681,6 +3693,18 @@ function isImportedModuleReceiver(receiver: string, ref: UnresolvedRef, context:
return binding.isNamespace || context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true;
}
/** A method a Vue Options API component declares for itself (`index::handleLogin` in `index.vue`). */
function isVueComponentMethod(n: Node): boolean {
return n.kind === 'method' && n.filePath.endsWith('.vue') && n.qualifiedName === `${path.posix.basename(n.filePath, '.vue')}::${n.name}`;
}
/** Is `ref` written as `this.<name>(` in the file that declares `n`? */
function isThisCallInOwnFile(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
if (n.filePath !== ref.filePath) return false;
const line = (context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/))?.[ref.line - 1] ?? '';
return new RegExp(String.raw`\bthis\s*\??\.\s*${n.name.replace(/\$/g, '\\$')}\s*\(`).test(line);
}
/** Is the root of a member call's receiver (`CameraManager` in `CameraManager.x`) one of the file's imports? */
function isImportBinding(receiver: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
const root = receiver.split('.')[0]!;