Repository navigation
feat(agent): add AST-aware structural search tool for precise symbol navigation #29396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dylanyunlon
wants to merge
23
commits into
google-gemini:main
Choose a base branch
from
dylanyunlon:feat/ast-aware-tools
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
a77704a
feat(agent): add AST-aware structural search tool for precise symbol …
dylanyunlon c81acef
fix: address gemini-code-assist review feedback
dylanyunlon a1b136e
fix: address round-2 gemini-code-assist review
dylanyunlon 9550488
fix: address round-3 gemini-code-assist review
dylanyunlon c78c03c
fix: address round-4 gemini-code-assist review
dylanyunlon 1fa514d
fix: address round-5 gemini-code-assist review
dylanyunlon 7006078
fix: address round-6 gemini-code-assist review
dylanyunlon 8adea18
fix: address round-7 gemini-code-assist review
dylanyunlon 193cf2c
fix: address round-8 gemini-code-assist review + own testing fixes
dylanyunlon 30f295b
fix: address round-9 gemini-code-assist review
dylanyunlon ef6fe41
fix: address round-10 gemini-code-assist review
dylanyunlon fdbbed1
fix: address round-11 gemini-code-assist review
dylanyunlon a9a36c4
fix: address round-12 review - deterministic file collection
dylanyunlon 0d1c5ad
fix: address round-13 review - regex literals + collection optimization
dylanyunlon 31df4f2
fix(security): enforce .gitignore/.geminiignore patterns in ast_searc…
dylanyunlon d530426
fix(perf): deduplicate file reads in handleSymbolScope by using singl…
dylanyunlon 52ad215
fix(java): support multiple modifiers and generic return types in Jav…
dylanyunlon fec4ec4
fix(perf): use centralized FileDiscoveryService and harden regex lite…
dylanyunlon 2d24111
fix(lang): add Rust impl blocks, Java records/constructors, Go interf…
dylanyunlon a3561c8
fix: distinguish file-not-parsed from symbol-not-found and optimize b…
dylanyunlon 34b349e
fix: clamp parenDepth and anchor Java member patterns
dylanyunlon a6a5036
fix: filter control flow keywords from member extraction
dylanyunlon 233e60a
fix: defensive bounds checks, case-insensitive extensions, remove reg…
dylanyunlon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,295 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | ||
| import * as fs from 'node:fs/promises'; | ||
| import * as path from 'node:path'; | ||
| import * as os from 'node:os'; | ||
| import { | ||
| ASTAnalysisService, | ||
| extractSymbols, | ||
| findClosingBrace, | ||
| findIndentEnd, | ||
| } from './astAnalysisService.js'; | ||
|
|
||
| describe('ASTAnalysisService', () => { | ||
| let tmpDir: string; | ||
|
|
||
| beforeEach(async () => { | ||
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ast-svc-')); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe('findClosingBrace', () => { | ||
| it('should find the closing brace of a simple block', () => { | ||
| const lines = ['function f() {', ' return 1;', '}']; | ||
| expect(findClosingBrace(lines, 0)).toBe(2); | ||
| }); | ||
|
|
||
| it('should handle nested braces correctly', () => { | ||
| const lines = [ | ||
| 'class C {', | ||
| ' m() {', | ||
| ' if (x) {', | ||
| ' }', | ||
| ' }', | ||
| '}', | ||
| ]; | ||
| expect(findClosingBrace(lines, 0)).toBe(5); | ||
| expect(findClosingBrace(lines, 1)).toBe(4); | ||
| }); | ||
|
|
||
| it('should ignore braces inside string literals', () => { | ||
| const lines = ['function f() {', ' const s = "}{";', '}']; | ||
| expect(findClosingBrace(lines, 0)).toBe(2); | ||
| }); | ||
| }); | ||
|
|
||
| describe('findIndentEnd', () => { | ||
| it('should find the end of a Python indentation block', () => { | ||
| const lines = ['def f():', ' x = 1', ' return x', 'def g():']; | ||
| expect(findIndentEnd(lines, 0)).toBe(2); | ||
| }); | ||
|
|
||
| it('should skip blank lines within a block', () => { | ||
| const lines = ['def f():', ' x = 1', '', ' y = 2', 'z = 3']; | ||
| expect(findIndentEnd(lines, 0)).toBe(3); | ||
| }); | ||
| }); | ||
|
|
||
| describe('extractSymbols', () => { | ||
| it('should extract TypeScript class with methods', () => { | ||
| const lines = [ | ||
| 'export class MyService {', | ||
| ' private val: number;', | ||
| ' public process(x: string): void {', | ||
| ' console.log(x);', | ||
| ' }', | ||
| '}', | ||
| ]; | ||
| const syms = extractSymbols(lines, 'typescript'); | ||
| expect(syms).toHaveLength(1); | ||
| expect(syms[0].name).toBe('MyService'); | ||
| expect(syms[0].kind).toBe('class'); | ||
| expect(syms[0].children.length).toBeGreaterThanOrEqual(1); | ||
| }); | ||
|
|
||
| it('should extract standalone functions', () => { | ||
| const lines = [ | ||
| 'export function doWork() {', | ||
| ' return 42;', | ||
| '}', | ||
| '', | ||
| 'export async function fetchData() {', | ||
| ' return null;', | ||
| '}', | ||
| ]; | ||
| const syms = extractSymbols(lines, 'typescript'); | ||
| expect(syms).toHaveLength(2); | ||
| expect(syms[0].name).toBe('doWork'); | ||
| expect(syms[1].name).toBe('fetchData'); | ||
| }); | ||
|
|
||
| it('should extract interfaces and types', () => { | ||
| const lines = [ | ||
| 'export interface Config {', | ||
| ' host: string;', | ||
| '}', | ||
| 'export type Status = "ok" | "err";', | ||
| ]; | ||
| const syms = extractSymbols(lines, 'typescript'); | ||
| expect( | ||
| syms.some((s) => s.name === 'Config' && s.kind === 'interface'), | ||
| ).toBe(true); | ||
| expect(syms.some((s) => s.name === 'Status' && s.kind === 'type')).toBe( | ||
| true, | ||
| ); | ||
| }); | ||
|
|
||
| it('should extract Python classes and functions', () => { | ||
| const lines = [ | ||
| 'class Handler:', | ||
| ' def run(self):', | ||
| ' pass', | ||
| 'def util():', | ||
| ' pass', | ||
| ]; | ||
| const syms = extractSymbols(lines, 'python'); | ||
| expect(syms).toHaveLength(2); | ||
| expect(syms[0].name).toBe('Handler'); | ||
| expect(syms[0].kind).toBe('class'); | ||
| expect(syms[0].children).toHaveLength(1); | ||
| expect(syms[0].children[0].name).toBe('run'); | ||
| expect(syms[0].children[0].kind).toBe('method'); | ||
| expect(syms[1].name).toBe('util'); | ||
| }); | ||
|
|
||
| it('should skip comments and imports', () => { | ||
| const lines = [ | ||
| '// comment', | ||
| 'import { X } from "y";', | ||
| 'export function real() {', | ||
| ' return 1;', | ||
| '}', | ||
| ]; | ||
| const syms = extractSymbols(lines, 'typescript'); | ||
| expect(syms).toHaveLength(1); | ||
| expect(syms[0].name).toBe('real'); | ||
| }); | ||
|
|
||
| it('should return empty array for empty input', () => { | ||
| expect(extractSymbols([], 'typescript')).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('should truncate long signatures', () => { | ||
| const longLine = 'export function ' + 'a'.repeat(130) + '() {'; | ||
| const syms = extractSymbols([longLine, '}'], 'typescript'); | ||
| expect(syms).toHaveLength(1); | ||
| expect(syms[0].signature.length).toBeLessThanOrEqual(120); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getFileOutline', () => { | ||
| it('should outline a TypeScript file from disk', async () => { | ||
| await fs.writeFile( | ||
| path.join(tmpDir, 'svc.ts'), | ||
| 'export class Svc {\n run() {\n return 1;\n }\n}\nexport function helper() {\n return 2;\n}\n', | ||
| ); | ||
| const service = new ASTAnalysisService(tmpDir); | ||
| const outline = await service.getFileOutline('svc.ts'); | ||
| expect(outline).not.toBeNull(); | ||
| expect(outline!.language).toBe('typescript'); | ||
| expect(outline!.symbols.length).toBeGreaterThanOrEqual(2); | ||
| }); | ||
|
|
||
| it('should return null for unsupported extensions', async () => { | ||
| await fs.writeFile(path.join(tmpDir, 'data.json'), '{}'); | ||
| const service = new ASTAnalysisService(tmpDir); | ||
| expect(await service.getFileOutline('data.json')).toBeNull(); | ||
| }); | ||
|
|
||
| it('should return null for missing files', async () => { | ||
| const service = new ASTAnalysisService(tmpDir); | ||
| expect(await service.getFileOutline('nope.ts')).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('findSymbolBounds', () => { | ||
| it('should locate a class precisely', async () => { | ||
| const content = [ | ||
| 'import { X } from "x";', | ||
| '', | ||
| 'export class Target {', | ||
| ' method() {', | ||
| ' return 1;', | ||
| ' }', | ||
| '}', | ||
| '', | ||
| 'export function other() {}', | ||
| ].join('\n'); | ||
| await fs.writeFile(path.join(tmpDir, 'f.ts'), content); | ||
| const svc = new ASTAnalysisService(tmpDir); | ||
| const bounds = await svc.findSymbolBounds('f.ts', 'Target'); | ||
| expect(bounds).not.toBeNull(); | ||
| expect(bounds!.startLine).toBe(3); | ||
| expect(bounds!.endLine).toBe(7); | ||
| }); | ||
|
|
||
| it('should return null for a non-existent symbol', async () => { | ||
| await fs.writeFile( | ||
| path.join(tmpDir, 'f.ts'), | ||
| 'export function real() {}\n', | ||
| ); | ||
| const svc = new ASTAnalysisService(tmpDir); | ||
| expect(await svc.findSymbolBounds('f.ts', 'ghost')).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getCodebaseMap', () => { | ||
| it('should map multiple source files', async () => { | ||
| const src = path.join(tmpDir, 'src'); | ||
| await fs.mkdir(src); | ||
| await fs.writeFile(path.join(src, 'a.ts'), 'export class A {}\n'); | ||
| await fs.writeFile(path.join(src, 'b.ts'), 'export function b() {}\n'); | ||
| await fs.writeFile(path.join(src, 'c.json'), '{}'); | ||
|
|
||
| const svc = new ASTAnalysisService(tmpDir); | ||
| const map = await svc.getCodebaseMap(); | ||
| expect(map).toContain('Codebase Map:'); | ||
| expect(map).toContain('class A'); | ||
| expect(map).toContain('function b'); | ||
| expect(map).not.toContain('.json'); | ||
| }); | ||
|
|
||
| it('should skip node_modules', async () => { | ||
| const nm = path.join(tmpDir, 'node_modules', 'pkg'); | ||
| await fs.mkdir(nm, { recursive: true }); | ||
| await fs.writeFile(path.join(nm, 'index.ts'), 'export class X {}\n'); | ||
| await fs.writeFile( | ||
| path.join(tmpDir, 'main.ts'), | ||
| 'export class Main {}\n', | ||
| ); | ||
|
|
||
| const svc = new ASTAnalysisService(tmpDir); | ||
| const map = await svc.getCodebaseMap(); | ||
| expect(map).toContain('Main'); | ||
| expect(map).not.toContain('node_modules'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('shouldIgnore callback', () => { | ||
| it('getFileOutline should return null for ignored files', async () => { | ||
| await fs.writeFile( | ||
| path.join(tmpDir, 'secret.ts'), | ||
| 'export class Secret {}\n', | ||
| ); | ||
| const ignoreFn = (p: string) => p.includes('secret'); | ||
| const svc = new ASTAnalysisService(tmpDir, ignoreFn); | ||
| expect(await svc.getFileOutline('secret.ts')).toBeNull(); | ||
| }); | ||
|
|
||
| it('getFileOutline should still work for non-ignored files', async () => { | ||
| await fs.writeFile( | ||
| path.join(tmpDir, 'public.ts'), | ||
| 'export class Public {}\n', | ||
| ); | ||
| const ignoreFn = (p: string) => p.includes('secret'); | ||
| const svc = new ASTAnalysisService(tmpDir, ignoreFn); | ||
| const outline = await svc.getFileOutline('public.ts'); | ||
| expect(outline).not.toBeNull(); | ||
| expect(outline!.symbols[0].name).toBe('Public'); | ||
| }); | ||
|
|
||
| it('getCodebaseMap should exclude ignored files', async () => { | ||
| await fs.writeFile( | ||
| path.join(tmpDir, 'visible.ts'), | ||
| 'export class Visible {}\n', | ||
| ); | ||
| await fs.writeFile( | ||
| path.join(tmpDir, 'hidden.ts'), | ||
| 'export class Hidden {}\n', | ||
| ); | ||
| const ignoreFn = (p: string) => p.includes('hidden'); | ||
| const svc = new ASTAnalysisService(tmpDir, ignoreFn); | ||
| const map = await svc.getCodebaseMap(); | ||
| expect(map).toContain('Visible'); | ||
| expect(map).not.toContain('Hidden'); | ||
| }); | ||
|
|
||
| it('findSymbolBounds should return null for ignored files', async () => { | ||
| await fs.writeFile( | ||
| path.join(tmpDir, 'ignored.ts'), | ||
| 'export function target() { return 1; }\n', | ||
| ); | ||
| const ignoreFn = (p: string) => p.includes('ignored'); | ||
| const svc = new ASTAnalysisService(tmpDir, ignoreFn); | ||
| expect(await svc.findSymbolBounds('ignored.ts', 'target')).toBeNull(); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current test only asserts the top-level symbols for Python class and function extraction, but does not verify that the class methods are correctly extracted as children. Updating the test to assert the children of the class ensures that the method extraction logic is covered and prevents future regressions.