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
12 changes: 12 additions & 0 deletions .changeset/go-rust-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@prosdevlab/dev-agent': minor
---

Go callee extraction and Rust language support

- Rust: full scanner — functions, structs, enums, traits, impl methods, imports, callees, doc comments
- Rust: pattern rules — try operator, match expression, unsafe block, impl/trait definitions
- Go: callee extraction for functions and methods — dev_refs now traces Go call chains
- Go: pattern rules — error handling (if err != nil), goroutines, defer, channels
- Generic impl type parameter stripping (Container<T>.show → Container.show)
- All MCP tools (dev_search, dev_refs, dev_map, dev_patterns) work with Go callees and Rust
2 changes: 2 additions & 0 deletions .claude/scratchpad.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- **`getDocsByFilePath` fetches all docs client-side (capped at 5k).** Uses `getAll(limit: 5000)` + exact path filter. Fine for single repos (dev-agent has ~2,200 docs). Won't scale to monorepos with 50k+ files. Future fix: server-side path filter in Antfly SDK.
- **Two clones of the same repo share one index.** Storage path is hashed from git remote URL (`prosdevlab/dev-agent` → `a1b2c3d4`). Two local clones on different branches share the same index, graph cache, and watcher snapshot. Stale data possible if branches diverge significantly. Pre-existing design — not introduced by graph cache. Fix would be to include branch or worktree path in the hash.
- **Antfly Linear Merge fails on large JSON payloads (~6k+ docs).** Tested with cli/cli (5,933 docs): `decoding request: json: string unexpected end of JSON input`. Chunking is NOT viable — merge semantics require ALL records in one call. Filed as [antflydb/antfly#37](https://github.com/antflydb/antfly/issues/37). AJ will take a look. Blocks indexing repos with >~5k components.
- **Rust/Go callee extraction does not resolve target files.** tree-sitter callees have `name` and `line` but no `file` field (unlike ts-morph which resolves cross-file references). This means `dev_map` hot paths show 0 refs for Rust/Go repos, and `dev_refs --depends-on` won't trace cross-file paths. The dependency graph only has edges when callees include a `file` field. Future: cross-file resolution for tree-sitter languages.

## Open Questions

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Everything runs on your machine. No data leaves.

```
packages/
core/ # Scanner (ts-morph, tree-sitter for Python/Go), vector storage (Antfly), services
core/ # Scanner (ts-morph, tree-sitter for Python/Go/Rust), vector storage (Antfly), services
cli/ # Commander.js CLI — dev index, dev search, dev refs, dev map, dev mcp install
mcp-server/ # MCP server with 5 built-in adapters
subagents/ # Coordinator, explorer, planner, PR agents
Expand Down
15 changes: 11 additions & 4 deletions packages/core/src/pattern-matcher/__tests__/wasm-matcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ function App() {
});

it('returns empty map for unsupported language', async () => {
const results = await matcher.match('fn main() {}', 'rust', ERROR_HANDLING_QUERIES);
const results = await matcher.match('fn main() {}', 'dart', ERROR_HANDLING_QUERIES);
expect(results.size).toBe(0);
});
});
Expand Down Expand Up @@ -346,10 +346,17 @@ describe('resolveLanguage', () => {
expect(resolveLanguage('components/App.jsx')).toBe('javascript');
});

it('maps .go to go', () => {
expect(resolveLanguage('main.go')).toBe('go');
});

it('maps .rs to rust', () => {
expect(resolveLanguage('main.rs')).toBe('rust');
});

it('returns undefined for unsupported extensions', () => {
expect(resolveLanguage('main.py')).toBe('python');
expect(resolveLanguage('main.go')).toBeUndefined(); // Go has scanner, not pattern matcher
expect(resolveLanguage('README.md')).toBeUndefined();
expect(resolveLanguage('main.dart')).toBeUndefined();
});
});

Expand Down Expand Up @@ -443,7 +450,7 @@ describe('extractErrorHandlingWithAst', () => {

it('unsupported extension → runAllAstQueries returns empty → regex', async () => {
const source = 'throw new Error("bad");';
const ast = await runAllAstQueries(source, 'test.rs', matcher);
const ast = await runAllAstQueries(source, 'test.dart', matcher);
expect(ast.size).toBe(0); // unsupported language
expect(extractErrorHandlingWithAst(source, ast)).toEqual(
extractErrorHandlingFromContent(source)
Expand Down
79 changes: 79 additions & 0 deletions packages/core/src/pattern-matcher/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,82 @@ export const ALL_PYTHON_QUERIES: PatternMatchRule[] = [
...PYTHON_IMPORT_QUERIES,
...PYTHON_TYPE_QUERIES,
];

// ============================================================================
// Go Error Handling + Concurrency (5 rules)
// ============================================================================

export const GO_ERROR_HANDLING_QUERIES: PatternMatchRule[] = [
{
id: 'go-if-err',
category: 'error-handling',
query: '(if_statement condition: (binary_expression right: (nil))) @match',
},
{
id: 'go-defer',
category: 'error-handling',
query: '(defer_statement) @match',
},
];

export const GO_CONCURRENCY_QUERIES: PatternMatchRule[] = [
{
id: 'go-goroutine',
category: 'concurrency',
query: '(go_statement) @match',
},
{
id: 'go-channel-send',
category: 'concurrency',
query: '(send_statement) @match',
},
];

export const ALL_GO_QUERIES: PatternMatchRule[] = [
...GO_ERROR_HANDLING_QUERIES,
...GO_CONCURRENCY_QUERIES,
];

// ============================================================================
// Rust Error Handling + Unsafe + Types (5 rules)
// ============================================================================

export const RUST_ERROR_HANDLING_QUERIES: PatternMatchRule[] = [
{
id: 'rust-try-operator',
category: 'error-handling',
query: '(try_expression) @match',
},
{
id: 'rust-match',
category: 'error-handling',
query: '(match_expression) @match',
},
];

export const RUST_UNSAFE_QUERIES: PatternMatchRule[] = [
{
id: 'rust-unsafe-block',
category: 'unsafe',
query: '(unsafe_block) @match',
},
];

export const RUST_TYPE_QUERIES: PatternMatchRule[] = [
{
id: 'rust-impl-block',
category: 'types',
query: '(impl_item) @match',
},
{
id: 'rust-trait-def',
category: 'types',
query: '(trait_item) @match',
},
];

export const ALL_RUST_QUERIES: PatternMatchRule[] = [
...RUST_ERROR_HANDLING_QUERIES,
...RUST_UNSAFE_QUERIES,
...RUST_TYPE_QUERIES,
];
11 changes: 10 additions & 1 deletion packages/core/src/pattern-matcher/wasm-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const EXTENSION_TO_LANGUAGE: Record<string, TreeSitterLanguage> = {
'.js': 'javascript',
'.jsx': 'javascript',
'.py': 'python',
'.go': 'go',
'.rs': 'rust',
};

/**
Expand All @@ -62,7 +64,14 @@ class WasmPatternMatcher implements PatternMatcher {
queries: PatternMatchRule[]
): Promise<Map<string, number>> {
// Validate language is supported
const supportedLanguages = new Set<string>(['typescript', 'tsx', 'javascript', 'go', 'python']);
const supportedLanguages = new Set<string>([
'typescript',
'tsx',
'javascript',
'go',
'python',
'rust',
]);
if (!supportedLanguages.has(language)) {
return new Map();
}
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/scanner/__fixtures__/rust-complex.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use std::fmt;

/// Server handles HTTP requests
pub struct Server {
host: String,
port: u16,
}

pub trait Handler {
fn handle(&self, request: &str) -> Result<String, Box<dyn std::error::Error>>;
}

impl Handler for Server {
fn handle(&self, request: &str) -> Result<String, Box<dyn std::error::Error>> {
let processed = self.process_request(request)?;
Ok(processed)
}
}

impl Server {
pub fn new(host: &str, port: u16) -> Self {
Server { host: host.to_string(), port }
}

fn process_request(&self, data: &str) -> Result<String, Box<dyn std::error::Error>> {
let trimmed = data.trim();
let result = format!("{}:{} - {}", self.host, self.port, trimmed);
Ok(result)
}
}

impl fmt::Display for Server {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Server({}:{})", self.host, self.port)
}
}

/// Generic container — tests type parameter stripping
pub struct Container<T> {
value: T,
}

impl<T: fmt::Display> Container<T> {
pub fn show(&self) -> String {
self.value.to_string()
}
}

fn transform(input: &str) -> String {
input.to_uppercase()
}

/// Tests callee extraction inside closures
pub fn process_items(items: Vec<String>) -> Vec<String> {
items.iter().map(|x| transform(x)).collect()
}

/// Tests that field access is NOT a callee
pub fn read_server_host(s: &Server) -> String {
let _host = s.host.clone();
s.host.to_uppercase()
}

// Tests mod block support — functions inside mod blocks must be captured
mod handlers {
pub fn handle_request(data: &str) -> String {
data.to_uppercase()
}

fn internal_helper() -> bool {
true
}
}

// Tests nested generic stripping
pub struct Wrapper<T> {
inner: Option<T>,
}

impl<T: fmt::Display> Wrapper<Option<T>> {
pub fn unwrap_display(&self) -> String {
format!("{:?}", self.inner)
}
}
4 changes: 4 additions & 0 deletions packages/core/src/scanner/__fixtures__/rust-malformed.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fn broken( {
// This file intentionally has a syntax error
let x =
}
54 changes: 54 additions & 0 deletions packages/core/src/scanner/__fixtures__/rust-simple.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use std::collections::HashMap;
use std::io::{self, Read};

/// A simple key-value store
pub struct Store {
data: HashMap<String, String>,
}

impl Store {
/// Create a new empty store
pub fn new() -> Self {
Store { data: HashMap::new() }
}

/// Get a value by key
pub fn get(&self, key: &str) -> Option<&String> {
self.data.get(key)
}

fn internal_cleanup(&mut self) {
self.data.clear();
}
}

/// Process input from stdin
pub fn process_input() -> io::Result<String> {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
Ok(buffer)
}

fn helper() -> bool {
true
}

/// Only visible within the crate
pub(crate) fn crate_visible() -> bool {
helper()
}

pub enum Status {
Active,
Inactive,
Error(String),
}

pub trait Processor {
fn process(&self, input: &str) -> String;
}

/// Async function for testing async detection
pub async fn fetch_data(url: &str) -> Result<String, Box<dyn std::error::Error>> {
Ok(url.to_string())
}
33 changes: 33 additions & 0 deletions packages/core/src/scanner/__tests__/fixtures/go/callees.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package main

import (
"fmt"
"os"
"strings"
)

func processInput(input string) string {
trimmed := strings.TrimSpace(input)
fmt.Println("Processing:", trimmed)
return trimmed
}

func main() {
result := processInput(os.Args[1])
fmt.Println(result)
os.Exit(0)
}

type Server struct {
host string
}

func (s *Server) Start() error {
fmt.Println("Starting server on", s.host)
return nil
}

func (s *Server) handleRequest(data string) {
processed := processInput(data)
fmt.Println("Handled:", processed)
}
Loading
Loading