Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Vue templates are indexed as uses: `:prop="x"`, `@event="save"`, `v-if` / `v-for` / `v-model` values and `{{ mustache }}` expressions now link to the script state, functions and imports they bind, so "where is `myLinkBrandingLocked` used" answers from the graph and `codegraph_explore` shows the template lines that bind a symbol you named.

- `codegraph_explore` no longer spends its budget on files that match only a plain word in the query when the query also names a symbol exactly: after the named symbol's file, files wired to that symbol come before an unrelated file that shares a word like "section".

- The Claude Code prompt hook no longer runs on the task-notification messages Claude Code injects when a background agent finishes, removing a multi-second stall on every such turn. (#1832)

- Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861)
Expand Down
49 changes: 49 additions & 0 deletions __tests__/explore-nl-stopword-collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,52 @@ describe('codegraph_explore — interface members never corroborate a bare word'
expect(files[0]).toBe('src/extensionHostProcess.ts');
});
});

describe('codegraph_explore — precise-before-plain file tier', () => {
let testDir: string;
let cg: CodeGraph;

afterEach(() => {
cg?.destroy();
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
});

it('a file tied to the exactly-named symbol outranks files that match only a plain query word', async () => {
// `WorkspaceMyLinkView appearance section myLinkBrandingLocked` rendered the
// view first and then filled the budget with a PHP layout class that matched
// only "section", ahead of everything wired to `myLinkBrandingLocked`.
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-precise-first-'));
const profile = path.join(testDir, 'src', 'profile');
const layout = path.join(testDir, 'src', 'layout');
fs.mkdirSync(profile, { recursive: true });
fs.mkdirSync(layout, { recursive: true });
fs.writeFileSync(path.join(profile, 'branding.ts'),
'export function myLinkBrandingLocked(plan: string): boolean {\n' +
" return plan !== 'pro';\n" +
'}\n');
fs.writeFileSync(path.join(profile, 'panel.ts'),
"import { myLinkBrandingLocked } from './branding';\n" +
'export function drawOwnDomainPanel(plan: string): string {\n' +
" return myLinkBrandingLocked(plan) ? 'upgrade' : 'edit';\n" +
'}\n');
// A dense mesh of `section` symbols: FTS roots for the plain word "section".
fs.writeFileSync(path.join(layout, 'sections.ts'),
'export function section(name: string): string {\n return renderSection(name);\n}\n' +
'export function renderSection(name: string): string {\n return sectionHeader(name) + sectionBody(name);\n}\n' +
'export function sectionHeader(name: string): string {\n return `<h2>${name}</h2>`;\n}\n' +
'export function sectionBody(name: string): string {\n return section.name + name;\n}\n' +
'export function sectionList(names: string[]): string {\n return names.map(section).join("");\n}\n');
cg = CodeGraph.initSync(testDir);
await cg.indexAll();

const res = await new ToolHandler(cg).execute('codegraph_explore', {
query: 'myLinkBrandingLocked appearance section',
});
const files = sourcedFiles(res.content[0].text as string);
const panel = files.indexOf('src/profile/panel.ts');
const sections = files.indexOf('src/layout/sections.ts');
expect(files[0]).toBe('src/profile/branding.ts');
expect(panel).toBeGreaterThan(0);
if (sections !== -1) expect(panel).toBeLessThan(sections);
});
});
44 changes: 44 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9219,6 +9219,50 @@ import MyButton from './MyButton.vue';
expect(refs).not.toContain('Span');
});

it('should extract identifiers the Vue template binds, skipping locals, keys and globals', () => {
const code = `<template>
<section v-if="!myLinkBrandingLocked" :class="{ active: isActive }">
<OwnDomainSection
:locked="myLinkBrandingLocked"
@save="saveDomain"
/>
<li v-for="(item, index) in domains" :key="item.id">{{ formatDomain(item) }} {{ Math.max(index, 1) }}</li>
<Row #default="{ row }">{{ row.label }}</Row>
<p>{{ $t('label') }} {{ 'isActive' }}</p>
</section>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import OwnDomainSection from './OwnDomainSection.vue';
import { formatDomain } from './format';

const isActive = ref(false);
const domains = ref<string[]>([]);
const myLinkBrandingLocked = computed(() => false);
function saveDomain() {}
</script>
`;
const result = extractFromSource('WorkspaceMyLinkView.vue', code);
const templateRefs = result.unresolvedReferences.filter((r) => r.line <= 11);
const named = (kind: string) => templateRefs.filter((r) => r.referenceKind === kind).map((r) => r.referenceName);

// Bound script state is a reference, on the template line that binds it.
const locked = templateRefs.filter((r) => r.referenceName === 'myLinkBrandingLocked');
expect(locked.map((r) => r.line).sort()).toEqual([2, 4]);
expect(named('references')).toContain('isActive');
expect(named('references')).toContain('domains');
// A bare event handler and a template call are calls.
expect(named('calls')).toContain('saveDomain');
expect(named('calls')).toContain('formatDomain');
// Locals, object keys, member tails, globals, $-helpers and string contents bind nothing.
const all = templateRefs.map((r) => r.referenceName);
for (const absent of ['item', 'index', 'row', 'active', 'id', 'label', 'Math', 'max', '$t']) {
expect(all).not.toContain(absent);
}
expect(templateRefs.filter((r) => r.referenceName === 'isActive')).toHaveLength(1);
});

it('should extract from both <script> and <script setup> blocks', () => {
const code = `<template>
<div>{{ msg }}</div>
Expand Down
184 changes: 181 additions & 3 deletions src/extraction/vue-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,50 @@ const VUE_BUILTIN_COMPONENTS = new Set([
'Slot',
]);

/** Keywords and literals a template expression can contain that bind nothing. */
const TEMPLATE_NON_BINDINGS = new Set([
'true', 'false', 'null', 'undefined', 'this', 'in', 'of', 'typeof', 'instanceof',
'new', 'void', 'delete', 'await', 'async', 'return', 'if', 'else', 'let', 'const',
'var', 'function', 'class', 'NaN', 'Infinity',
]);

/** Globals Vue exposes to templates; a call to one is not a call into the repo. */
const JS_GLOBALS = new Set([
'Math', 'JSON', 'Object', 'Array', 'Number', 'String', 'Boolean', 'Date', 'RegExp',
'Map', 'Set', 'Promise', 'Symbol', 'BigInt', 'Intl', 'Error', 'console', 'window',
'document', 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURI',
'encodeURIComponent', 'decodeURI', 'decodeURIComponent', 'require',
]);

/**
* Local names a `<script>` block's ES imports bind: `Foo` from `import Foo`,
* `a` and `c` from `import { a, b as c }`, `ns` from `import * as ns`.
* Type-only imports bind nothing a template can use.
*/
function importedLocalNames(script: string): string[] {
const names: string[] = [];
const importRegex = /(?:^|[\n;])\s*import\s+(?!type\s)([^'";]+?)\s+from\s*['"]/g;
let m: RegExpExecArray | null;
while ((m = importRegex.exec(script)) !== null) {
const clause = m[1]!;
const braces = /\{([\s\S]*)\}/.exec(clause);
const outside = braces ? clause.replace(braces[0], '') : clause;
for (const part of outside.split(',')) {
const ns = /\*\s*as\s+([A-Za-z_$][\w$]*)/.exec(part);
const id = ns ? ns[1] : /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(part)?.[1];
if (id) names.push(id);
}
if (braces) {
for (const spec of braces[1]!.split(',')) {
if (/^\s*type\s/.test(spec)) continue;
const id = /([A-Za-z_$][\w$]*)\s*$/.exec(spec.trim())?.[1];
if (id) names.push(id);
}
}
}
return names;
}

/** `my-component` → `MyComponent` (Vue allows either form in templates). */
function kebabToPascal(name: string): string {
return name
Expand Down Expand Up @@ -61,15 +105,26 @@ export class VueExtractor {
// Extract and process script blocks
const scriptBlocks = this.extractScriptBlocks();

// Names the <script> blocks bind (declarations + imports) — the only
// names a template identifier can reach in Vue besides globals.
const scriptBindings = new Set<string>();
for (const block of scriptBlocks) {
this.processScriptBlock(block, componentNode.id);
for (const name of this.processScriptBlock(block, componentNode.id)) scriptBindings.add(name);
for (const name of importedLocalNames(block.content)) scriptBindings.add(name);
}

// Extract component usages from the <template> (<ComponentName>).
// Without this, a Vue component used only in another component's
// markup (incl. through a barrel import) is invisible to callers /
// impact (#629 follow-up).
this.extractTemplateComponents(componentNode.id);

// Extract identifiers the template binds (`:prop="x"`, `@click="save"`,
// `v-if="locked"`, `{{ label }}`). This is where a component's state is
// actually rendered, so without it "where is `myLinkBrandingLocked`
// used" had no answer from the graph and explore never reached the
// template lines that bind it.
this.extractTemplateBindings(componentNode.id, scriptBindings);
} catch (error) {
this.errors.push({
message: `Vue extraction error: ${error instanceof Error ? error.message : String(error)}`,
Expand Down Expand Up @@ -171,7 +226,7 @@ export class VueExtractor {
private processScriptBlock(
block: { content: string; startLine: number; isSetup: boolean; isTypeScript: boolean },
componentNodeId: string
): void {
): string[] {
const scriptLanguage: Language = block.isTypeScript ? 'typescript' : 'javascript';

// Check if the script language parser is available
Expand All @@ -180,7 +235,7 @@ export class VueExtractor {
message: `Parser for ${scriptLanguage} not available, cannot parse Vue script block`,
severity: 'warning',
});
return;
return [];
}

// Delegate to TreeSitterExtractor
Expand Down Expand Up @@ -226,6 +281,129 @@ export class VueExtractor {
}
this.errors.push(error);
}

return result.nodes
.filter((n) => n.kind !== 'import' && n.kind !== 'export')
.map((n) => n.name);
}

/**
* Extract the identifiers a Vue `<template>` binds as references.
*
* Scans directive values (`:prop` / `v-bind:`, `@event` / `v-on:`, `v-if`,
* `v-show`, `v-model`, `v-for`'s source, ...) and `{{ mustache }}`
* interpolations. In each expression:
*
* - `fn(...)` is a `calls` reference (as the Svelte extractor does for
* `{fn(...)}`), unless `fn` is a JS global, a `$`-prefixed Vue instance
* helper (`$emit`, `$t`) or a template-local.
* - A bare identifier is a `references` reference ONLY when a `<script>`
* block declares or imports that name. Template scope is exactly those
* bindings plus globals, so the restriction drops nothing real, and it
* keeps object-literal keys and props-only names from name-matching a
* same-named symbol elsewhere in the repo (silent beats wrong).
* - An `@event="save"` handler that is a bare name is a `calls` reference:
* Vue invokes it.
*
* `v-for` aliases and slot props (`v-slot="{ item }"`, `#row="{ item }"`)
* are template-locals and never emitted.
*/
private extractTemplateBindings(componentNodeId: string, scriptBindings: Set<string>): void {
// Blank out <script>/<style> blocks and HTML comments, keeping every
// newline, so offsets in the masked text are offsets in the file.
const blank = (m: string) => m.replace(/[^\n]/g, ' ');
const masked = this.source
.replace(/<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g, blank)
.replace(/<!--[\s\S]*?-->/g, blank);

const lineStarts: number[] = [0];
for (let i = 0; i < masked.length; i++) if (masked[i] === '\n') lineStarts.push(i + 1);
const lineOf = (offset: number): number => {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid]! <= offset) lo = mid;
else hi = mid - 1;
}
return lo; // 0-indexed
};

// Directive attributes: `:x`, `@x`, `#x`, `v-xxx[:arg][.mod]`, with a quoted value.
const directiveRegex = /\s((?:v-[a-z][\w-]*|[:@#])[^\s=>"']*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
const expressions: Array<{ text: string; offset: number; handler: boolean }> = [];
const templateLocals = new Set<string>();
let m: RegExpExecArray | null;
while ((m = directiveRegex.exec(masked)) !== null) {
const name = m[1]!;
const value = m[2] ?? m[3] ?? '';
const valueOffset = m.index + m[0].length - value.length - 1;
if (name.startsWith('#') || name.startsWith('v-slot')) {
// Slot props declare template-locals; they reference nothing.
for (const id of value.match(/[A-Za-z_$][\w$]*/g) ?? []) templateLocals.add(id);
continue;
}
if (name === 'v-for') {
// `(item, index) in items` / `item of items`: aliases are locals,
// the source expression is a reference.
const split = /^\s*([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)$/.exec(value);
if (split) {
for (const id of split[1]!.match(/[A-Za-z_$][\w$]*/g) ?? []) templateLocals.add(id);
expressions.push({ text: split[2]!, offset: valueOffset + value.length - split[2]!.length, handler: false });
}
continue;
}
const handler = name.startsWith('@') || name.startsWith('v-on');
expressions.push({ text: value, offset: valueOffset, handler });
}
const mustacheRegex = /\{\{([\s\S]*?)\}\}/g;
while ((m = mustacheRegex.exec(masked)) !== null) {
expressions.push({ text: m[1]!, offset: m.index + 2, handler: false });
}

const seen = new Set<string>();
const emit = (name: string, kind: 'calls' | 'references', offset: number) => {
const line = lineOf(offset);
const key = `${kind}:${name}:${line}`;
if (seen.has(key)) return;
seen.add(key);
this.unresolvedReferences.push({
fromNodeId: componentNodeId,
referenceName: name,
referenceKind: kind,
line: line + 1, // 1-indexed
column: offset - lineStarts[line]! + 1,
filePath: this.filePath,
language: 'vue',
});
};

for (const expr of expressions) {
// Blank string literals (keeping length) so their words aren't read as identifiers.
const text = expr.text.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, blank);
const handlerName = expr.handler ? /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(text)?.[1] : undefined;
const idRegex = /[A-Za-z_$][\w$]*/g;
let id: RegExpExecArray | null;
while ((id = idRegex.exec(text)) !== null) {
const name = id[0];
const start = id.index;
const before = text.slice(0, start).replace(/\s+$/, '');
// A member (`obj.name`, `obj?.name`) is not a template binding; its head is.
if (before.endsWith('.')) continue;
// The tail of a number literal (`1e5`), not an identifier.
if (start > 0 && /[\w$]/.test(text[start - 1]!)) continue;
if (name.startsWith('$') || TEMPLATE_NON_BINDINGS.has(name) || templateLocals.has(name)) continue;
const after = text.slice(start + name.length);
// Object-literal key (`{ active: isActive }`), not a reference.
if (/^\s*:(?!:)/.test(after) && /[{,]$/.test(before)) continue;
const offset = expr.offset + start;
if (/^\s*\(/.test(after)) {
if (!JS_GLOBALS.has(name)) emit(name, 'calls', offset);
} else if (scriptBindings.has(name)) {
emit(name, name === handlerName ? 'calls' : 'references', offset);
}
}
}
}

/**
Expand Down
Loading