Merge pull request #89 from Lum1104/fix/graph-builder-use-language-registry

feat: multi-language tree-sitter extractor architecture (10 languages)
This commit is contained in:
Yuxiang Lin
2026-04-15 22:34:33 +08:00
committed by GitHub
39 changed files with 10847 additions and 776 deletions
@@ -0,0 +1,856 @@
# Language-Specific Extractor Architecture Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** (1) Decouple AST extraction logic from TS/JS-specific node types so 8 additional code languages (Python, Go, Rust, Java, Ruby, PHP, C/C++, C#) get tree-sitter-powered structural analysis. Swift and Kotlin are excluded — no WASM grammar packages available. (2) Replace the file-analyzer agent's ad-hoc regex script generation with a deterministic, pre-built tree-sitter extraction script.
**Architecture:** Introduce a `LanguageExtractor` interface that each language implements. `TreeSitterPlugin` delegates extraction to the registered extractor for the file's language. A bundled `extract-structure.mjs` script in `skills/understand/` uses `PluginRegistry` (which includes both `TreeSitterPlugin` and the non-code parsers) to provide deterministic structural extraction for the file-analyzer agent — replacing the current approach where the LLM writes throwaway regex scripts every run.
**Tech Stack:** web-tree-sitter (WASM), TypeScript, Vitest
---
## File Structure
```
packages/core/src/plugins/
├── extractors/
│ ├── types.ts # LanguageExtractor interface + TreeSitterNode re-export
│ ├── base-extractor.ts # Shared utilities (traverse, getStringValue)
│ ├── typescript-extractor.ts # TS/JS (moved from tree-sitter-plugin.ts)
│ ├── python-extractor.ts
│ ├── go-extractor.ts
│ ├── rust-extractor.ts
│ ├── java-extractor.ts
│ ├── ruby-extractor.ts
│ ├── php-extractor.ts
│ ├── cpp-extractor.ts
│ ├── csharp-extractor.ts
│ └── index.ts # builtinExtractors array + re-exports
├── tree-sitter-plugin.ts # Refactored to use extractors
└── tree-sitter-plugin.test.ts # Existing tests (should still pass)
packages/core/src/plugins/__tests__/
└── extractors.test.ts # Tests for all new extractors
skills/understand/
├── extract-structure.mjs # Pre-built tree-sitter extraction script (NEW)
└── SKILL.md # Updated to reference extract-structure.mjs
agents/
└── file-analyzer.md # Phase 1 rewritten to execute pre-built script
```
---
### Task 1: Create LanguageExtractor interface and shared utilities
**Files:**
- Create: `packages/core/src/plugins/extractors/types.ts`
- Create: `packages/core/src/plugins/extractors/base-extractor.ts`
- [ ] **Step 1: Create the extractor interface**
```typescript
// packages/core/src/plugins/extractors/types.ts
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
// Re-export the tree-sitter Node type for use by extractors
export type TreeSitterNode = import("web-tree-sitter").Node;
/**
* Language-specific extractor that maps a tree-sitter AST
* to the common StructuralAnalysis / CallGraphEntry types.
*/
export interface LanguageExtractor {
/** Language IDs this extractor handles (must match LanguageConfig.id) */
languageIds: string[];
/** Extract functions, classes, imports, exports from the root AST node */
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis;
/** Extract caller→callee relationships from the root AST node */
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[];
}
```
- [ ] **Step 2: Create base-extractor with shared utilities**
Move `traverse()` and `getStringValue()` from `tree-sitter-plugin.ts` into a shared module:
```typescript
// packages/core/src/plugins/extractors/base-extractor.ts
import type { TreeSitterNode } from "./types.js";
/** Recursively traverse an AST tree, calling the visitor for each node. */
export function traverse(
node: TreeSitterNode,
visitor: (node: TreeSitterNode) => void,
): void {
visitor(node);
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) traverse(child, visitor);
}
}
/** Extract the unquoted string value from a string-like node. */
export function getStringValue(node: TreeSitterNode): string {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === "string_fragment") {
return child.text;
}
}
return node.text.replace(/^['"`]|['"`]$/g, "");
}
/** Find the first child matching a type. */
export function findChild(node: TreeSitterNode, type: string): TreeSitterNode | null {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === type) return child;
}
return null;
}
/** Find all children matching a type. */
export function findChildren(node: TreeSitterNode, type: string): TreeSitterNode[] {
const result: TreeSitterNode[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === type) result.push(child);
}
return result;
}
/** Check if a node has a child of the given type (used for export/visibility checks). */
export function hasChildOfType(node: TreeSitterNode, type: string): boolean {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === type) return true;
}
return false;
}
```
- [ ] **Step 3: Commit**
```bash
git add packages/core/src/plugins/extractors/types.ts packages/core/src/plugins/extractors/base-extractor.ts
git commit -m "feat: add LanguageExtractor interface and shared base utilities"
```
---
### Task 2: Move TS/JS extraction logic into TypeScriptExtractor
**Files:**
- Create: `packages/core/src/plugins/extractors/typescript-extractor.ts`
- Modify: `packages/core/src/plugins/tree-sitter-plugin.ts`
This is a pure refactor. All existing tests must still pass with zero changes.
- [ ] **Step 1: Create TypeScriptExtractor**
Move all the TS/JS-specific extraction methods (`extractFunction`, `extractClass`, `extractVariableDeclarations`, `extractImport`, `processExportStatement`, `extractParams`, `extractReturnType`, `extractImportSpecifiers`, and the call graph walker) from `tree-sitter-plugin.ts` into `typescript-extractor.ts`, implementing the `LanguageExtractor` interface.
The `languageIds` should be `["typescript", "javascript"]`. Do NOT include `"tsx"` — it is a synthetic key internal to `TreeSitterPlugin` for grammar selection, not a `LanguageConfig.id`. The tsx→typescript mapping is handled in `getExtractor()` below.
- [ ] **Step 2: Refactor TreeSitterPlugin to use extractors**
Replace the hardcoded extraction logic in `TreeSitterPlugin` with extractor dispatch:
```typescript
// In TreeSitterPlugin
private extractors = new Map<string, LanguageExtractor>();
registerExtractor(extractor: LanguageExtractor): void {
for (const id of extractor.languageIds) {
this.extractors.set(id, extractor);
}
}
private getExtractor(langKey: string): LanguageExtractor | null {
// tsx is a synthetic grammar key — extraction logic is identical to typescript
const key = langKey === "tsx" ? "typescript" : langKey;
return this.extractors.get(key) ?? null;
}
```
The `analyzeFile()` method becomes:
```typescript
analyzeFile(filePath: string, content: string): StructuralAnalysis {
const parser = this.getParser(filePath);
if (!parser) return { functions: [], classes: [], imports: [], exports: [] };
const tree = parser.parse(content);
if (!tree) { parser.delete(); return { functions: [], classes: [], imports: [], exports: [] }; }
const langKey = this.languageKeyFromPath(filePath);
const extractor = langKey ? this.getExtractor(langKey) : null;
let result: StructuralAnalysis;
if (extractor) {
result = extractor.extractStructure(tree.rootNode);
} else {
result = { functions: [], classes: [], imports: [], exports: [] };
}
tree.delete();
parser.delete();
return result;
}
```
The `extractCallGraph()` method follows the same pattern — parser lifecycle must be managed identically:
```typescript
extractCallGraph(filePath: string, content: string): CallGraphEntry[] {
const parser = this.getParser(filePath);
if (!parser) return [];
const tree = parser.parse(content);
if (!tree) { parser.delete(); return []; }
const langKey = this.languageKeyFromPath(filePath);
const extractor = langKey ? this.getExtractor(langKey) : null;
const result = extractor ? extractor.extractCallGraph(tree.rootNode) : [];
tree.delete();
parser.delete();
return result;
}
```
The constructor should accept an optional `extractors` array and register them. If none provided, register the built-in `TypeScriptExtractor` for backward compatibility.
- [ ] **Step 3: Run existing tests to verify zero behavior change**
Run: `pnpm --filter @understand-anything/core test`
Expected: All 426 tests pass (identical to before)
- [ ] **Step 4: Commit**
```bash
git add packages/core/src/plugins/extractors/typescript-extractor.ts packages/core/src/plugins/tree-sitter-plugin.ts
git commit -m "refactor: move TS/JS extraction logic to TypeScriptExtractor, dispatch via LanguageExtractor interface"
```
---
### Task 2.5: Add extractCallGraph to PluginRegistry and update DEFAULT_PLUGIN_CONFIG
**Files:**
- Modify: `packages/core/src/plugins/registry.ts`
- Modify: `packages/core/src/plugins/discovery.ts`
**Context:** `PluginRegistry` currently only exposes `analyzeFile` and `resolveImports` — it has no `extractCallGraph`. The `extract-structure.mjs` script (Task 13) needs call graph data through the registry. Also, `DEFAULT_PLUGIN_CONFIG` hardcodes `["typescript", "javascript"]` which needs to reflect all supported languages.
- [ ] **Step 1: Add extractCallGraph to PluginRegistry**
```typescript
// In PluginRegistry (registry.ts)
extractCallGraph(filePath: string, content: string): CallGraphEntry[] | null {
const plugin = this.getPluginForFile(filePath);
if (!plugin?.extractCallGraph) return null;
return plugin.extractCallGraph(filePath, content);
}
```
- [ ] **Step 2: Update DEFAULT_PLUGIN_CONFIG to derive languages dynamically**
In `discovery.ts`, replace the hardcoded `["typescript", "javascript"]` with a dynamic derivation from `builtinLanguageConfigs`:
```typescript
import { builtinLanguageConfigs } from "../languages/configs/index.js";
export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
plugins: [
{
name: "tree-sitter",
enabled: true,
languages: builtinLanguageConfigs
.filter((c) => c.treeSitter)
.map((c) => c.id),
},
],
};
```
- [ ] **Step 3: Run tests, commit**
```bash
pnpm --filter @understand-anything/core test
git add packages/core/src/plugins/registry.ts packages/core/src/plugins/discovery.ts
git commit -m "feat: add extractCallGraph to PluginRegistry, derive DEFAULT_PLUGIN_CONFIG from configs"
```
---
### Task 3: Add npm dependencies and treeSitter configs for all 10 languages
**Files:**
- Modify: `packages/core/package.json` (add 8 deps: python, go, rust, java, ruby, php, cpp, c-sharp)
- Modify: 10 config files in `packages/core/src/languages/configs/`
- [ ] **Step 1: Add tree-sitter grammar dependencies to package.json**
Add to `dependencies`:
```json
"tree-sitter-c-sharp": "^0.23.1",
"tree-sitter-cpp": "^0.23.4",
"tree-sitter-go": "^0.25.0",
"tree-sitter-java": "^0.23.5",
"tree-sitter-php": "^0.23.11",
"tree-sitter-python": "^0.25.0",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "^0.24.0"
```
Then run `pnpm install`.
- [ ] **Step 2: Add treeSitter field to all 10 language configs**
Each config gets a `treeSitter` block. Examples:
```typescript
// python.ts
treeSitter: { wasmPackage: "tree-sitter-python", wasmFile: "tree-sitter-python.wasm" },
// go.ts
treeSitter: { wasmPackage: "tree-sitter-go", wasmFile: "tree-sitter-go.wasm" },
// rust.ts
treeSitter: { wasmPackage: "tree-sitter-rust", wasmFile: "tree-sitter-rust.wasm" },
// java.ts
treeSitter: { wasmPackage: "tree-sitter-java", wasmFile: "tree-sitter-java.wasm" },
// ruby.ts
treeSitter: { wasmPackage: "tree-sitter-ruby", wasmFile: "tree-sitter-ruby.wasm" },
// php.ts
treeSitter: { wasmPackage: "tree-sitter-php", wasmFile: "tree-sitter-php.wasm" },
// cpp.ts
treeSitter: { wasmPackage: "tree-sitter-cpp", wasmFile: "tree-sitter-cpp.wasm" },
// csharp.ts
treeSitter: { wasmPackage: "tree-sitter-c-sharp", wasmFile: "tree-sitter-c_sharp.wasm" },
```
Note: Swift and Kotlin configs are NOT changed (no WASM packages available).
- [ ] **Step 3: Run pnpm install and verify WASM files resolve**
```bash
pnpm install
node -e "const r=require('module').createRequire(import.meta.url??__filename); console.log(r.resolve('tree-sitter-python/tree-sitter-python.wasm'))"
```
- [ ] **Step 4: Commit**
```bash
git add packages/core/package.json pnpm-lock.yaml packages/core/src/languages/configs/
git commit -m "feat: add tree-sitter grammar deps and treeSitter configs for 10 languages"
```
---
### Task 4: Create Python extractor
**Files:**
- Create: `packages/core/src/plugins/extractors/python-extractor.ts`
- [ ] **Step 1: Write the Python extractor**
Key Python tree-sitter node types:
- Functions: `function_definition` (name, parameters, return_type)
- Classes: `class_definition` (name, body → methods + assignments as properties)
- Imports: `import_statement`, `import_from_statement`
- Decorated: `decorated_definition` wrapping function_definition or class_definition
- Calls: `call` (function field)
- No formal exports (all top-level names are "exported")
```typescript
languageIds: ["python"]
```
- [ ] **Step 2: Write tests for Python extractor**
Test with representative Python code:
```python
import os
from pathlib import Path
from typing import Optional
class DataProcessor:
name: str
def __init__(self, name: str):
self.name = name
def process(self, data: list) -> dict:
return transform(data)
def helper(x: int) -> str:
return str(x)
@decorator
def decorated_func():
pass
```
Verify: 2 functions (helper, decorated_func), 1 class (DataProcessor with methods __init__/process and property name), 3 imports, call graph (process→transform).
- [ ] **Step 3: Run tests**
Run: `pnpm --filter @understand-anything/core test`
- [ ] **Step 4: Commit**
---
### Task 5: Create Go extractor
**Files:**
- Create: `packages/core/src/plugins/extractors/go-extractor.ts`
- [ ] **Step 1: Write the Go extractor**
Key Go tree-sitter node types:
- Functions: `function_declaration` (name, parameter_list, result)
- Methods: `method_declaration` (receiver, name, parameter_list, result)
- Structs: `type_declaration``type_spec``struct_type`
- Interfaces: `type_declaration``type_spec``interface_type`
- Imports: `import_declaration``import_spec_list``import_spec`
- Exports: capitalized first letter of name
- Calls: `call_expression` (function field)
```typescript
languageIds: ["go"]
```
- [ ] **Step 2: Write tests**
Test with:
```go
package main
import (
"fmt"
"os"
)
type Server struct {
Host string
Port int
}
func (s *Server) Start() error {
fmt.Println("starting")
return nil
}
func NewServer(host string, port int) *Server {
return &Server{Host: host, Port: port}
}
```
Verify: 2 functions (Start, NewServer), 1 class/struct (Server with method Start, properties Host/Port), 2 imports, exports (Server, Start, NewServer — all capitalized), call graph (Start→fmt.Println).
- [ ] **Step 3: Run tests and commit**
---
### Task 6: Create Rust extractor
**Files:**
- Create: `packages/core/src/plugins/extractors/rust-extractor.ts`
- [ ] **Step 1: Write the Rust extractor**
Key Rust tree-sitter node types:
- Functions: `function_item` (name, parameters, return_type via `->`)
- Structs: `struct_item` (name, field_declaration_list)
- Enums: `enum_item`
- Impl blocks: `impl_item` (type, body containing function_items)
- Traits: `trait_item`
- Imports: `use_declaration` (scoped_identifier, use_list, use_wildcard)
- Exports: `visibility_modifier` containing `pub`
- Calls: `call_expression` (function field)
```typescript
languageIds: ["rust"]
```
- [ ] **Step 2: Write tests**
Test with:
```rust
use std::collections::HashMap;
use std::io::{self, Read};
pub struct Config {
name: String,
port: u16,
}
impl Config {
pub fn new(name: String, port: u16) -> Self {
Config { name, port }
}
fn validate(&self) -> bool {
check_port(self.port)
}
}
pub fn check_port(port: u16) -> bool {
port > 0
}
```
Verify: 3 functions (new, validate, check_port), 1 class/struct (Config with methods new/validate, properties name/port), 2 imports, exports (Config, new, check_port — those with `pub`), call graph (validate→check_port).
- [ ] **Step 3: Run tests and commit**
---
### Task 7: Create Java extractor
**Files:**
- Create: `packages/core/src/plugins/extractors/java-extractor.ts`
- [ ] **Step 1: Write the Java extractor**
Key Java tree-sitter node types:
- Methods: `method_declaration` (name, formal_parameters, type/dimensions)
- Constructors: `constructor_declaration` (name, formal_parameters)
- Classes: `class_declaration` (name, class_body)
- Interfaces: `interface_declaration`
- Fields: `field_declaration` (declarator → variable_declarator → identifier)
- Imports: `import_declaration` (scoped_identifier)
- Exports: `public` modifier (modifiers node)
- Calls: `method_invocation` (name, object, arguments)
```typescript
languageIds: ["java"]
```
- [ ] **Step 2: Write tests with representative Java code, run, commit**
---
### Task 8: Create Ruby extractor
**Files:**
- Create: `packages/core/src/plugins/extractors/ruby-extractor.ts`
- [ ] **Step 1: Write the Ruby extractor**
Key Ruby tree-sitter node types:
- Methods: `method` (name, parameters)
- Classes: `class` (name, body containing methods)
- Modules: `module` (name)
- Imports: `call` where method is `require` or `require_relative` (Ruby uses method calls for imports)
- Calls: `call` (method, receiver, arguments)
- No formal export syntax
```typescript
languageIds: ["ruby"]
```
- [ ] **Step 2: Write tests, run, commit**
---
### Task 9: Create PHP extractor
**Files:**
- Create: `packages/core/src/plugins/extractors/php-extractor.ts`
- [ ] **Step 1: Write the PHP extractor**
Key PHP tree-sitter node types:
- Functions: `function_definition` (name, formal_parameters, return_type)
- Methods: `method_declaration` (name, formal_parameters, return_type)
- Classes: `class_declaration` (name, declaration_list)
- Imports: `namespace_use_declaration` (namespace_use_clause)
- Calls: `function_call_expression` / `member_call_expression`
- Note: PHP tree wraps everything in a `program``php_tag` + statements
```typescript
languageIds: ["php"]
```
- [ ] **Step 2: Write tests, run, commit**
---
### Task 10: Create C/C++ extractor
**Files:**
- Create: `packages/core/src/plugins/extractors/cpp-extractor.ts`
- [ ] **Step 1: Write the C/C++ extractor**
Key C/C++ tree-sitter node types:
- Functions: `function_definition` (declarator → function_declarator → identifier + parameter_list)
- Classes: `class_specifier` (name, body → field_declaration_list)
- Structs: `struct_specifier` (name, body)
- Includes: `preproc_include` (path → string_literal or system_lib_string)
- Namespaces: `namespace_definition`
- Calls: `call_expression` (function, arguments)
Note: C/C++ function signatures are nested (the name is inside a `function_declarator` inside the `declarator` field).
The `cppConfig` has `id: "cpp"` and `extensions: [".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".hxx"]`. Pure C files (`.c`, `.h`) are parsed with the C++ grammar, which works but won't produce C++-specific node types like `class_specifier`. The extractor must handle their absence gracefully (return empty arrays for classes when parsing pure C).
```typescript
languageIds: ["cpp"]
```
- [ ] **Step 2: Write tests for both C++ and pure C code, run, commit**
---
### Task 11: Create C# extractor
**Files:**
- Create: `packages/core/src/plugins/extractors/csharp-extractor.ts`
- [ ] **Step 1: Write the C# extractor**
Key C# tree-sitter node types:
- Methods: `method_declaration` (name, parameter_list, return type)
- Constructors: `constructor_declaration`
- Classes: `class_declaration` (name, declaration_list)
- Interfaces: `interface_declaration`
- Properties: `property_declaration` (name, type)
- Imports: `using_directive` (qualified_name)
- Calls: `invocation_expression` (identifier/member_access, argument_list)
```typescript
languageIds: ["csharp"]
```
- [ ] **Step 2: Write tests, run, commit**
---
### Task 12: Create extractor index and wire into TreeSitterPlugin
**Files:**
- Create: `packages/core/src/plugins/extractors/index.ts`
- Modify: `packages/core/src/plugins/tree-sitter-plugin.ts` (import builtinExtractors)
- [ ] **Step 1: Create index.ts exporting all extractors**
```typescript
// packages/core/src/plugins/extractors/index.ts
export type { LanguageExtractor, TreeSitterNode } from "./types.js";
export { traverse, getStringValue, findChild, findChildren, hasChildOfType } from "./base-extractor.js";
export { TypeScriptExtractor } from "./typescript-extractor.js";
export { PythonExtractor } from "./python-extractor.js";
export { GoExtractor } from "./go-extractor.js";
export { RustExtractor } from "./rust-extractor.js";
export { JavaExtractor } from "./java-extractor.js";
export { RubyExtractor } from "./ruby-extractor.js";
export { PhpExtractor } from "./php-extractor.js";
export { CppExtractor } from "./cpp-extractor.js";
export { CSharpExtractor } from "./csharp-extractor.js";
import type { LanguageExtractor } from "./types.js";
import { TypeScriptExtractor } from "./typescript-extractor.js";
import { PythonExtractor } from "./python-extractor.js";
import { GoExtractor } from "./go-extractor.js";
import { RustExtractor } from "./rust-extractor.js";
import { JavaExtractor } from "./java-extractor.js";
import { RubyExtractor } from "./ruby-extractor.js";
import { PhpExtractor } from "./php-extractor.js";
import { CppExtractor } from "./cpp-extractor.js";
import { CSharpExtractor } from "./csharp-extractor.js";
export const builtinExtractors: LanguageExtractor[] = [
new TypeScriptExtractor(),
new PythonExtractor(),
new GoExtractor(),
new RustExtractor(),
new JavaExtractor(),
new RubyExtractor(),
new PhpExtractor(),
new CppExtractor(),
new CSharpExtractor(),
];
```
- [ ] **Step 2: Wire builtinExtractors into TreeSitterPlugin constructor**
When no extractors are provided, default to `builtinExtractors`.
- [ ] **Step 3: Run full test suite**
Run: `pnpm --filter @understand-anything/core test`
Expected: All tests pass (existing + new extractor tests)
- [ ] **Step 4: Commit**
---
### Task 13: Create bundled extract-structure.mjs script
**Files:**
- Create: `skills/understand/extract-structure.mjs`
**Context:** Currently the file-analyzer agent (Phase 1) instructs the LLM to write a throwaway regex-based Node.js/Python script every run. This is slow, non-deterministic, and ignores the tree-sitter infrastructure we just built. This task replaces that with a pre-built script that uses `PluginRegistry` (which routes to `TreeSitterPlugin` for code files and to the regex parsers for non-code files).
- [ ] **Step 1: Create extract-structure.mjs**
The script:
1. Accepts input JSON path (arg 1) and output JSON path (arg 2)
2. Input format matches what file-analyzer.md already specifies: `{ projectRoot, batchFiles: [{path, language, sizeLines, fileCategory}], batchImportData }`
3. Resolves `@understand-anything/core` from the plugin's own `node_modules` using `createRequire` relative to the script's own location (two directories up to plugin root)
4. Creates a `PluginRegistry` with `TreeSitterPlugin` (all builtin language configs) + all non-code parsers registered
5. For each file: reads content, calls `registry.analyzeFile()`, formats output to match the existing script output schema (functions, classes, exports, sections, definitions, services, etc.)
6. For code files with tree-sitter support: also extracts call graph via `plugin.extractCallGraph()`
7. For files where no plugin exists (Swift, Kotlin, unknown languages): outputs `{ path, language, fileCategory, totalLines, nonEmptyLines, metrics }` with empty structural data — the LLM agent handles these in Phase 2
8. Writes output JSON matching the existing `scriptCompleted/filesAnalyzed/filesSkipped/results` schema
Key resolution logic (with fallback for different install layouts):
```javascript
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pluginRoot = resolve(__dirname, '../..');
const require = createRequire(resolve(pluginRoot, 'package.json'));
let core;
try {
core = await import(require.resolve('@understand-anything/core'));
} catch {
// Fallback: direct path for installed plugin cache where pnpm symlinks may differ
core = await import(resolve(pluginRoot, 'packages/core/dist/index.js'));
}
```
- [ ] **Step 2: Test the script locally**
Create a small test input JSON with a TS file, a Python file, and a YAML file. Run:
```bash
node skills/understand/extract-structure.mjs test-input.json test-output.json
```
Verify the output contains structural data for all three.
- [ ] **Step 3: Commit**
```bash
git add skills/understand/extract-structure.mjs
git commit -m "feat: add bundled tree-sitter extraction script for file-analyzer agent"
```
---
### Task 14: Rewrite file-analyzer.md Phase 1 to use bundled script
**Files:**
- Modify: `agents/file-analyzer.md`
**Context:** Phase 1 currently has ~150 lines instructing the agent to write a custom extraction script from scratch. Replace this with a short section that tells the agent to execute the pre-built `extract-structure.mjs` script.
- [ ] **Step 1: Replace Phase 1 in file-analyzer.md**
Delete the entire current Phase 1 (~150 lines of regex script generation instructions). Replace with:
1. Tell the agent to prepare the input JSON file (same format as before):
```bash
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json << 'ENDJSON'
{
"projectRoot": "<project-root>",
"batchFiles": [<this batch's files including fileCategory>],
"batchImportData": <batchImportData JSON>
}
ENDJSON
```
2. Execute the bundled script:
```bash
node <SKILL_DIR>/extract-structure.mjs \
$PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json \
$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json
```
3. If the script exits non-zero, read stderr, diagnose and report the error. Do NOT fall back to writing a manual script — the bundled script is the sole extraction path.
4. Keep the existing output format — Phase 2 (semantic analysis) is unchanged.
- [ ] **Step 2: Update SKILL.md to pass SKILL_DIR to file-analyzer dispatch**
In SKILL.md Phase 2, the file-analyzer dispatch prompt must include the skill directory path so the agent can locate `extract-structure.mjs`.
Add to the dispatch parameters:
```
> Skill directory (for bundled scripts): `<SKILL_DIR>`
```
This follows the established pattern — SKILL.md already passes `<SKILL_DIR>` for `merge-batch-graphs.py` (line 213) and `merge-subdomain-graphs.py` (line 44) using the same mechanism.
- [ ] **Step 3: Verify the file-analyzer output format is unchanged**
Phase 2 of file-analyzer.md should NOT need changes — it reads the same JSON structure from the script results. Verify the output schema from `extract-structure.mjs` matches what Phase 2 expects.
- [ ] **Step 4: Commit**
```bash
git add agents/file-analyzer.md skills/understand/SKILL.md
git commit -m "feat: file-analyzer uses bundled tree-sitter script instead of LLM-generated regex"
```
---
### Task 15: Final integration verification and cleanup
- [ ] **Step 1: Add exports to packages/core/src/index.ts**
This is required — `extract-structure.mjs` and external consumers need these exports:
```typescript
export type { LanguageExtractor } from "./plugins/extractors/types.js";
export { builtinExtractors } from "./plugins/extractors/index.js";
```
- [ ] **Step 2: Build the full package**
```bash
pnpm --filter @understand-anything/core build
```
- [ ] **Step 3: Run full test suite one final time**
```bash
pnpm --filter @understand-anything/core test
```
- [ ] **Step 4: Final commit**
```bash
git commit -m "feat: complete language extractor architecture — 10 languages with tree-sitter support"
```
---
## Implementation Notes
**Test file convention:** Each language extractor gets its own test file at `packages/core/src/plugins/extractors/__tests__/<language>-extractor.test.ts`. This follows the existing pattern where `tree-sitter-plugin.test.ts` is co-located.
**Lazy grammar loading (future optimization):** The current `TreeSitterPlugin.init()` loads all grammar WASMs upfront via `Promise.all`. With 10 grammars (~12MB total WASM), this may cause noticeable init delay. A future improvement: load TS/JS eagerly (most common), defer others to first use. Not required for this PR — measure first.
**Fingerprint side effect:** `buildFingerprintStore` in `fingerprint.ts` uses `PluginRegistry.analyzeFile` internally. Once the new extractors are wired up, fingerprinting for Python/Go/Rust/etc. will automatically produce structural fingerprints instead of content-hash-only. No code changes needed — it happens for free.
**PHP grammar note:** `tree-sitter-php` ships both `tree-sitter-php.wasm` (full PHP + embedded HTML/CSS/JS) and `tree-sitter-php_only.wasm` (PHP only). We use `tree-sitter-php.wasm`. The PHP extractor should be robust to non-PHP AST nodes that appear when parsing files with embedded HTML templates.
+142
View File
@@ -45,9 +45,33 @@ importers:
ignore:
specifier: ^7.0.5
version: 7.0.5
tree-sitter-c-sharp:
specifier: ^0.23.1
version: 0.23.5
tree-sitter-cpp:
specifier: ^0.23.4
version: 0.23.4
tree-sitter-go:
specifier: ^0.25.0
version: 0.25.0
tree-sitter-java:
specifier: ^0.23.5
version: 0.23.5
tree-sitter-javascript:
specifier: ^0.25.0
version: 0.25.0
tree-sitter-php:
specifier: ^0.23.11
version: 0.23.12
tree-sitter-python:
specifier: ^0.25.0
version: 0.25.0
tree-sitter-ruby:
specifier: ^0.23.1
version: 0.23.1
tree-sitter-rust:
specifier: ^0.24.0
version: 0.24.0
tree-sitter-typescript:
specifier: ^0.23.2
version: 0.23.2
@@ -2353,6 +2377,46 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tree-sitter-c-sharp@0.23.5:
resolution: {integrity: sha512-xJGOeXPMmld0nES5+080N/06yY6LQi+KWGWV4LfZaZe6srJPtUtfhIbRSN7EZN6IaauzW28v6W4QHFwmeUW6HQ==}
peerDependencies:
tree-sitter: ^0.25.0
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-c@0.23.6:
resolution: {integrity: sha512-0dxXKznVyUA0s6PjNolJNs2yF87O5aL538A/eR6njA5oqX3C3vH4vnx3QdOKwuUdpKEcFdHuiDpRKLLCA/tjvQ==}
peerDependencies:
tree-sitter: ^0.22.1
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-cpp@0.23.4:
resolution: {integrity: sha512-qR5qUDyhZ5jJ6V8/umiBxokRbe89bCGmcq/dk94wI4kN86qfdV8k0GHIUEKaqWgcu42wKal5E97LKpLeVW8sKw==}
peerDependencies:
tree-sitter: ^0.21.1
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-go@0.25.0:
resolution: {integrity: sha512-APBc/Dq3xz/e35Xpkhb1blu5UgW+2E3RyGWawZSCNcbGwa7jhSQPS8KsUupuzBla8PCo8+lz9W/JDJjmfRa2tw==}
peerDependencies:
tree-sitter: ^0.25.0
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-java@0.23.5:
resolution: {integrity: sha512-Yju7oQ0Xx7GcUT01mUglPP+bYfvqjNCGdxqigTnew9nLGoII42PNVP3bHrYeMxswiCRM0yubWmN5qk+zsg0zMA==}
peerDependencies:
tree-sitter: ^0.21.1
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-javascript@0.23.1:
resolution: {integrity: sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==}
peerDependencies:
@@ -2369,6 +2433,38 @@ packages:
tree-sitter:
optional: true
tree-sitter-php@0.23.12:
resolution: {integrity: sha512-VwkBVOahhC2NYXK/Fuqq30NxuL/6c2hmbxEF4jrB7AyR5rLc7nT27mzF3qoi+pqx9Gy2AbXnGezF7h4MeM6YRA==}
peerDependencies:
tree-sitter: ^0.21.1
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-python@0.25.0:
resolution: {integrity: sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw==}
peerDependencies:
tree-sitter: ^0.25.0
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-ruby@0.23.1:
resolution: {integrity: sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA==}
peerDependencies:
tree-sitter: ^0.21.1
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-rust@0.24.0:
resolution: {integrity: sha512-NWemUDf629Tfc90Y0Z55zuwPCAHkLxWnMf2RznYu4iBkkrQl2o/CHGB7Cr52TyN5F1DAx8FmUnDtCy9iUkXZEQ==}
peerDependencies:
tree-sitter: ^0.22.1
peerDependenciesMeta:
tree-sitter:
optional: true
tree-sitter-typescript@0.23.2:
resolution: {integrity: sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==}
peerDependencies:
@@ -5220,6 +5316,32 @@ snapshots:
tinyspy@4.0.4: {}
tree-sitter-c-sharp@0.23.5:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-c@0.23.6:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-cpp@0.23.4:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-c: 0.23.6
tree-sitter-go@0.25.0:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-java@0.23.5:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-javascript@0.23.1:
dependencies:
node-addon-api: 8.6.0
@@ -5230,6 +5352,26 @@ snapshots:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-php@0.23.12:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-python@0.25.0:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-ruby@0.23.1:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-rust@0.24.0:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
tree-sitter-typescript@0.23.2:
dependencies:
node-addon-api: 8.6.0
@@ -19,148 +19,39 @@ For each file in the batch provided to you, extract structural data via a script
---
## Phase 1 -- Structural Extraction Script
## Phase 1 -- Structural Extraction (Bundled Script)
Write a script that reads each file in your batch and extracts deterministic structural information. Prefer Node.js for the script; fall back to Python if Node.js is unavailable. Avoid bash for complex extraction — it handles multiline patterns poorly.
Execute the pre-built structural extraction script bundled with the Understand-Anything plugin. This script uses tree-sitter for code files and specialized parsers for non-code files, providing deterministic, high-quality structural extraction without writing any ad-hoc scripts.
### Script Requirements
### Step 1 — Prepare the input JSON
1. **Accept** a JSON file path as the first argument. This JSON file contains:
```json
{
"projectRoot": "/path/to/project",
"batchFiles": [
{"path": "src/index.ts", "language": "typescript", "sizeLines": 150, "fileCategory": "code"},
{"path": "README.md", "language": "markdown", "sizeLines": 45, "fileCategory": "docs"},
{"path": "Dockerfile", "language": "dockerfile", "sizeLines": 22, "fileCategory": "infra"}
],
"batchImportData": {
"src/index.ts": ["src/utils.ts", "src/config.ts"],
"README.md": [],
"Dockerfile": []
}
}
```
2. **Write** results JSON to the path given as the second argument.
3. **Exit 0** on success. **Exit 1** on fatal error (print error to stderr).
Create the input file with the batch data. **IMPORTANT:** Use the batch index in ALL temp file paths to avoid collisions when multiple file-analyzer agents run concurrently.
### What the Script Must Extract (Per File)
```bash
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json << 'ENDJSON'
{
"projectRoot": "<project-root>",
"batchFiles": [<this batch's files including fileCategory>],
"batchImportData": <batchImportData JSON object — provided in your dispatch prompt>
}
ENDJSON
```
The extraction approach depends on the file's `fileCategory`:
### Step 2 — Execute the bundled extraction script
#### For `code` files:
Run the bundled `extract-structure.mjs` script. The `<SKILL_DIR>` path is provided in your dispatch prompt.
**Functions and Methods:**
- Name, start line, end line, parameter names
- Detection approach: match `function <name>`, `const <name> = (`, `<name>(` in class bodies, `def <name>`, `func <name>`, `fn <name>`, `pub fn <name>` as appropriate for the language
- Include exported arrow functions and method definitions
```bash
node <SKILL_DIR>/extract-structure.mjs \
$PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json \
$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json
```
**Classes, Interfaces, and Types:**
- Name, start line, end line
- Method names and property names within the class body
- Detection approach: match `class <name>`, `interface <name>`, `type <name> =`, `struct <name>`, `trait <name>`, `impl <name>` as appropriate
If the script exits non-zero, read stderr and report the error. Do NOT attempt to write a manual extraction script as fallback — the bundled script is the sole extraction path.
**Imports:**
- Do NOT extract imports in the script. Import resolution has already been performed by the project scanner.
- The pre-resolved imports for each file are provided in `batchImportData` in the input JSON.
- Do not include an `imports` field in the script output — import edges will be created in Phase 2 using `batchImportData` directly.
### Step 3 — Read the extraction results
**Exports:**
- Exported names and their line numbers
- Whether it is a default export, named export, or re-export
**Basic Metrics:**
- Total line count
- Non-empty line count (lines that are not blank or comment-only)
- Import count — use `batchImportData[file.path].length` from the input JSON (do not count from source)
- Export count (number of export statements)
- Function count, class count
#### For `config` files (YAML, JSON, TOML, XML, .env, etc.):
**Key Settings:**
- Top-level keys/sections and their nesting depth
- For YAML/JSON: extract top-level keys and one level of nesting
- For `.env` files: extract variable names (not values)
- For `tsconfig.json`, `package.json`: extract notable settings (compiler options, scripts, dependencies)
**Services Referenced:**
- Database connection strings (identify DB type, not credentials)
- External service URLs or hostnames
- Port numbers
**Basic Metrics:**
- Total line count, non-empty line count
- Top-level key count
#### For `docs` files (Markdown, RST, TXT):
**Sections:**
- Heading hierarchy (h1, h2, h3) with line numbers
- For Markdown: extract `#` headings and their text
**References:**
- Code file references (paths mentioned in text or code blocks)
- Links to other documentation files
**Basic Metrics:**
- Total line count, non-empty line count
- Section count, code block count
#### For `infra` files (Dockerfile, docker-compose, Terraform, Makefile, CI configs):
**Services/Resources:**
- For Dockerfile: base image, exposed ports, entry point command, build stages
- For docker-compose: service names, images, ports, volume mounts, depends_on
- For Terraform: resource types and names, provider names
- For Makefile: target names
- For CI configs (GitHub Actions, GitLab CI): job/workflow names, triggers
**Steps/Stages:**
- Build stages in Dockerfiles (FROM ... AS ...)
- CI pipeline stages/jobs
- Makefile targets and their dependencies
**Basic Metrics:**
- Total line count, non-empty line count
- Stage count / job count / target count
#### For `data` files (SQL, GraphQL, Protobuf, Prisma):
**Definitions:**
- For SQL: table names (CREATE TABLE), column names and types, foreign key relationships
- For GraphQL: type definitions, query/mutation names, field lists
- For Protobuf: message names, field names, service definitions
- For Prisma: model names, field names, relations
**Relationships:**
- Foreign keys and references between tables/types
- Service dependencies
**Basic Metrics:**
- Total line count, non-empty line count
- Table/type/message count, field count
#### For `script` files (shell, PowerShell, batch):
Treat similarly to `code` files:
- Extract function definitions (`function name()` or `name()` in bash)
- Extract significant commands and pipeline operations
- Basic metrics: total lines, non-empty lines, function count
#### For `markup` files (HTML, CSS, SCSS):
**Structural Elements:**
- For HTML: major semantic elements (`<main>`, `<nav>`, `<header>`, `<footer>`), component references, script/link tags
- For CSS/SCSS: selector patterns, media queries, CSS custom properties (variables)
**Basic Metrics:**
- Total line count, non-empty line count
- Selector count (CSS) or element count (HTML)
### Script Output Format
The script must write this exact JSON structure to the output file:
Read `$PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json`. The output format is:
```json
{
@@ -181,8 +72,10 @@ The script must write this exact JSON structure to the output file:
{"name": "App", "startLine": 50, "endLine": 140, "methods": ["init", "run"], "properties": ["config", "logger"]}
],
"exports": [
{"name": "App", "line": 50, "isDefault": true},
{"name": "createApp", "line": 145, "isDefault": false}
{"name": "App", "line": 50, "isDefault": false}
],
"callGraph": [
{"caller": "main", "callee": "initApp", "lineNumber": 15}
],
"metrics": {
"importCount": 5,
@@ -190,90 +83,12 @@ The script must write this exact JSON structure to the output file:
"functionCount": 4,
"classCount": 1
}
},
{
"path": "README.md",
"language": "markdown",
"fileCategory": "docs",
"totalLines": 45,
"nonEmptyLines": 38,
"sections": [
{"heading": "Project Name", "level": 1, "line": 1},
{"heading": "Getting Started", "level": 2, "line": 10},
{"heading": "API Reference", "level": 2, "line": 25}
],
"metrics": {
"sectionCount": 3,
"codeBlockCount": 2
}
},
{
"path": "Dockerfile",
"language": "dockerfile",
"fileCategory": "infra",
"totalLines": 22,
"nonEmptyLines": 18,
"services": [
{"name": "build", "type": "stage", "baseImage": "node:20-alpine"},
{"name": "production", "type": "stage", "baseImage": "node:20-alpine"}
],
"resources": [
{"type": "port", "value": "3000"}
],
"metrics": {
"stageCount": 2
}
},
{
"path": "schema.sql",
"language": "sql",
"fileCategory": "data",
"totalLines": 80,
"nonEmptyLines": 65,
"definitions": [
{"name": "users", "type": "table", "columns": ["id", "email", "name", "created_at"]},
{"name": "orders", "type": "table", "columns": ["id", "user_id", "total", "status"]}
],
"metrics": {
"tableCount": 2,
"columnCount": 8
}
}
]
}
```
- `scriptCompleted` (boolean) -- always `true` when the script finishes normally
- `filesAnalyzed` (integer) -- count of files successfully processed
- `filesSkipped` (string[]) -- files that could not be read (binary, permission error, etc.)
- `results` (array) -- one entry per successfully analyzed file
### Preparing the Script Input
Before writing the script, create its input JSON file. **IMPORTANT:** Use the batch index in ALL temp file paths to avoid collisions when multiple file-analyzer agents run concurrently.
```bash
cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json << 'ENDJSON'
{
"projectRoot": "<project-root>",
"batchFiles": [<this batch's files including fileCategory>],
"batchImportData": <batchImportData JSON object — provided in your dispatch prompt>
}
ENDJSON
```
### Executing the Script
After writing the script, execute it. **Use the batch index in every temp file path** — multiple file-analyzer agents run in parallel and must not overwrite each other's files:
```bash
# For Node.js scripts:
node $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-<batchIndex>.js $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json
# For Python scripts:
python3 $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-<batchIndex>.py $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-<batchIndex>.json $PROJECT_ROOT/.understand-anything/tmp/ua-file-extract-results-<batchIndex>.json
```
If the script exits with a non-zero code, read stderr, diagnose the issue, fix the script, and re-run. You have up to 2 retry attempts.
**Supported file categories:** The bundled script handles all file categories — `code` (10 languages with tree-sitter: TypeScript, JavaScript, Python, Go, Rust, Java, Ruby, PHP, C/C++, C#), `config`, `docs`, `infra`, `data`, `script`, and `markup`. For languages without tree-sitter support (Swift, Kotlin), the script outputs basic metrics with empty structural data — use your judgment to supplement from source file reading if needed.
---
@@ -39,7 +39,15 @@
"dependencies": {
"fuse.js": "^7.1.0",
"ignore": "^7.0.5",
"tree-sitter-c-sharp": "^0.23.1",
"tree-sitter-cpp": "^0.23.4",
"tree-sitter-go": "^0.25.0",
"tree-sitter-java": "^0.23.5",
"tree-sitter-javascript": "^0.25.0",
"tree-sitter-php": "^0.23.11",
"tree-sitter-python": "^0.25.0",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "^0.24.0",
"tree-sitter-typescript": "^0.23.2",
"web-tree-sitter": "^0.26.6",
"yaml": "^2.8.3",
@@ -10,6 +10,7 @@ import type {
ResourceInfo,
SectionInfo,
} from "../types.js";
import { LanguageRegistry } from "../languages/language-registry.js";
interface FileMeta {
summary: string;
@@ -56,72 +57,6 @@ const KIND_TO_NODE_TYPE: Record<string, GraphNode["type"]> = {
output: "config",
};
const EXTENSION_LANGUAGE: Record<string, string> = {
// Code languages
".ts": "typescript",
".tsx": "typescript",
".js": "javascript",
".jsx": "javascript",
".mjs": "javascript",
".cjs": "javascript",
".py": "python",
".rb": "ruby",
".go": "go",
".rs": "rust",
".java": "java",
".kt": "kotlin",
".swift": "swift",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
".cs": "csharp",
".php": "php",
".lua": "lua",
// Non-code languages
".sh": "shell",
".bash": "shell",
".zsh": "shell",
".json": "json",
".jsonc": "json",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".xml": "xml",
".html": "html",
".htm": "html",
".css": "css",
".scss": "css",
".less": "css",
".md": "markdown",
".mdx": "markdown",
".sql": "sql",
".graphql": "graphql",
".gql": "graphql",
".proto": "protobuf",
".tf": "terraform",
".tfvars": "terraform",
".mk": "makefile",
".env": "env",
".csv": "csv",
".tsv": "csv",
".rst": "restructuredtext",
".ps1": "powershell",
".psm1": "powershell",
".psd1": "powershell",
".bat": "batch",
".cmd": "batch",
".txt": "plaintext",
".svg": "xml",
};
function detectLanguage(filePath: string): string {
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) return "unknown";
const ext = filePath.slice(lastDot).toLowerCase();
return EXTENSION_LANGUAGE[ext] ?? "unknown";
}
export class GraphBuilder {
private readonly nodes: GraphNode[] = [];
private readonly edges: GraphEdge[] = [];
@@ -130,10 +65,16 @@ export class GraphBuilder {
private readonly edgeKeys = new Set<string>();
private readonly projectName: string;
private readonly gitHash: string;
private readonly languageRegistry: LanguageRegistry;
constructor(projectName: string, gitHash: string) {
constructor(projectName: string, gitHash: string, languageRegistry?: LanguageRegistry) {
this.projectName = projectName;
this.gitHash = gitHash;
this.languageRegistry = languageRegistry ?? LanguageRegistry.createDefault();
}
private detectLanguage(filePath: string): string {
return this.languageRegistry.getForFile(filePath)?.id ?? "unknown";
}
private static basename(filePath: string): string {
@@ -141,7 +82,7 @@ export class GraphBuilder {
}
addFile(filePath: string, meta: FileMeta): void {
const lang = detectLanguage(filePath);
const lang = this.detectLanguage(filePath);
if (lang !== "unknown") {
this.languages.add(lang);
}
@@ -166,7 +107,7 @@ export class GraphBuilder {
analysis: StructuralAnalysis,
meta: FileAnalysisMeta,
): void {
const lang = detectLanguage(filePath);
const lang = this.detectLanguage(filePath);
if (lang !== "unknown") {
this.languages.add(lang);
}
@@ -267,7 +208,7 @@ export class GraphBuilder {
}
addNonCodeFile(filePath: string, meta: NonCodeFileMeta): string {
const lang = detectLanguage(filePath);
const lang = this.detectLanguage(filePath);
if (lang !== "unknown") this.languages.add(lang);
const name = GraphBuilder.basename(filePath);
const id = `${meta.nodeType ?? "file"}:${filePath}`;
@@ -11,6 +11,8 @@ export {
type GraphIssue,
} from "./schema.js";
export { TreeSitterPlugin } from "./plugins/tree-sitter-plugin.js";
export type { LanguageExtractor } from "./plugins/extractors/types.js";
export { builtinExtractors } from "./plugins/extractors/index.js";
export { GraphBuilder } from "./analyzer/graph-builder.js";
export {
buildFileAnalysisPrompt,
@@ -4,6 +4,10 @@ export const cppConfig = {
id: "cpp",
displayName: "C/C++",
extensions: [".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".hxx"],
treeSitter: {
wasmPackage: "tree-sitter-cpp",
wasmFile: "tree-sitter-cpp.wasm",
},
concepts: [
"templates",
"RAII",
@@ -4,6 +4,10 @@ export const csharpConfig = {
id: "csharp",
displayName: "C#",
extensions: [".cs"],
treeSitter: {
wasmPackage: "tree-sitter-c-sharp",
wasmFile: "tree-sitter-c_sharp.wasm",
},
concepts: [
"LINQ",
"async/await",
@@ -4,6 +4,10 @@ export const goConfig = {
id: "go",
displayName: "Go",
extensions: [".go"],
treeSitter: {
wasmPackage: "tree-sitter-go",
wasmFile: "tree-sitter-go.wasm",
},
concepts: [
"goroutines",
"channels",
@@ -4,6 +4,10 @@ export const javaConfig = {
id: "java",
displayName: "Java",
extensions: [".java"],
treeSitter: {
wasmPackage: "tree-sitter-java",
wasmFile: "tree-sitter-java.wasm",
},
concepts: [
"generics",
"annotations",
@@ -4,6 +4,10 @@ export const phpConfig = {
id: "php",
displayName: "PHP",
extensions: [".php"],
treeSitter: {
wasmPackage: "tree-sitter-php",
wasmFile: "tree-sitter-php.wasm",
},
concepts: [
"namespaces",
"traits",
@@ -4,6 +4,10 @@ export const pythonConfig = {
id: "python",
displayName: "Python",
extensions: [".py", ".pyi"],
treeSitter: {
wasmPackage: "tree-sitter-python",
wasmFile: "tree-sitter-python.wasm",
},
concepts: [
"decorators",
"list comprehensions",
@@ -4,6 +4,10 @@ export const rubyConfig = {
id: "ruby",
displayName: "Ruby",
extensions: [".rb", ".rake"],
treeSitter: {
wasmPackage: "tree-sitter-ruby",
wasmFile: "tree-sitter-ruby.wasm",
},
concepts: [
"blocks and procs",
"mixins",
@@ -4,6 +4,10 @@ export const rustConfig = {
id: "rust",
displayName: "Rust",
extensions: [".rs"],
treeSitter: {
wasmPackage: "tree-sitter-rust",
wasmFile: "tree-sitter-rust.wasm",
},
concepts: [
"ownership",
"borrowing",
@@ -1,3 +1,5 @@
import { builtinLanguageConfigs } from "../languages/configs/index.js";
export interface PluginEntry {
name: string;
enabled: boolean;
@@ -14,7 +16,9 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
{
name: "tree-sitter",
enabled: true,
languages: ["typescript", "javascript"],
languages: builtinLanguageConfigs
.filter((c) => c.treeSitter)
.map((c) => c.id),
},
],
};
@@ -0,0 +1,696 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { CppExtractor } from "../cpp-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + C++ grammar once
let Parser: any;
let Language: any;
let cppLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-cpp/tree-sitter-cpp.wasm",
);
cppLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(cppLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("CppExtractor", () => {
const extractor = new CppExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["cpp"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts top-level functions with params and return types", () => {
const { tree, parser, root } = parse(`
int add(int a, int b) {
return a + b;
}
void greet(const char* name) {
printf("Hello %s", name);
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("add");
expect(result.functions[0].params).toEqual(["a", "b"]);
expect(result.functions[0].returnType).toBe("int");
expect(result.functions[1].name).toBe("greet");
expect(result.functions[1].params).toEqual(["name"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts functions with no params", () => {
const { tree, parser, root } = parse(`
int get_value() {
return 42;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("get_value");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("int");
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line functions", () => {
const { tree, parser, root } = parse(`
int multiline(
int a,
int b
) {
int result = a + b;
return result;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
it("handles pointer and reference parameters", () => {
const { tree, parser, root } = parse(`
void process(int* ptr, const char& ref, int arr[]) {
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].params).toEqual(["ptr", "ref", "arr"]);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts class with properties and method declarations", () => {
const { tree, parser, root } = parse(`
class Server {
public:
std::string host;
int port;
void start();
int getPort() { return port; }
};
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["host", "port"]);
expect(result.classes[0].methods).toContain("start");
expect(result.classes[0].methods).toContain("getPort");
tree.delete();
parser.delete();
});
it("respects access specifiers for exports", () => {
const { tree, parser, root } = parse(`
class Foo {
private:
int secret;
void hidden();
public:
int visible;
void exposed();
};
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
// Public members should be exported
expect(exportNames).toContain("exposed");
// Private members should NOT be exported (except the class name itself)
expect(exportNames).not.toContain("hidden");
expect(exportNames).not.toContain("secret");
// The class itself is always exported
expect(exportNames).toContain("Foo");
tree.delete();
parser.delete();
});
it("defaults class members to private access", () => {
const { tree, parser, root } = parse(`
class Priv {
int x;
void secret();
};
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Priv");
// Members without access specifier in a class default to private
expect(exportNames).not.toContain("secret");
tree.delete();
parser.delete();
});
it("handles inline method definitions (function_definition inside class)", () => {
const { tree, parser, root } = parse(`
class Calculator {
public:
int add(int a, int b) { return a + b; }
};
`);
const result = extractor.extractStructure(root);
// Inline method should appear in both classes.methods and functions
expect(result.classes[0].methods).toContain("add");
const addFn = result.functions.find((f) => f.name === "add");
expect(addFn).toBeDefined();
expect(addFn!.params).toEqual(["a", "b"]);
expect(addFn!.returnType).toBe("int");
tree.delete();
parser.delete();
});
});
// ---- Structs ----
describe("extractStructure - structs", () => {
it("extracts struct with fields", () => {
const { tree, parser, root } = parse(`
struct Point {
int x;
int y;
};
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Point");
expect(result.classes[0].properties).toEqual(["x", "y"]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("defaults struct members to public access and exports them", () => {
const { tree, parser, root } = parse(`
struct Config {
int port;
void init();
};
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
// Struct members default to public
expect(exportNames).toContain("Config");
expect(exportNames).toContain("init");
tree.delete();
parser.delete();
});
});
// ---- Includes (imports) ----
describe("extractStructure - includes", () => {
it("extracts system includes (angle brackets)", () => {
const { tree, parser, root } = parse(`
#include <iostream>
#include <vector>
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("iostream");
expect(result.imports[0].specifiers).toEqual(["iostream"]);
expect(result.imports[1].source).toBe("vector");
tree.delete();
parser.delete();
});
it("extracts local includes (quoted)", () => {
const { tree, parser, root } = parse(`
#include "config.h"
#include "utils/helper.h"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("config.h");
expect(result.imports[0].specifiers).toEqual(["config.h"]);
expect(result.imports[1].source).toBe("utils/helper.h");
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`
#include <iostream>
#include "config.h"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Namespaces ----
describe("extractStructure - namespaces", () => {
it("extracts functions inside namespaces", () => {
const { tree, parser, root } = parse(`
namespace utils {
int add(int a, int b) {
return a + b;
}
void log(const char* msg) {}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
const names = result.functions.map((f) => f.name);
expect(names).toContain("add");
expect(names).toContain("log");
tree.delete();
parser.delete();
});
it("extracts classes inside namespaces", () => {
const { tree, parser, root } = parse(`
namespace models {
class User {
public:
std::string name;
int id;
};
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("User");
expect(result.classes[0].properties).toEqual(["name", "id"]);
tree.delete();
parser.delete();
});
});
// ---- Out-of-class method definitions ----
describe("extractStructure - out-of-class methods", () => {
it("associates out-of-class method with its class", () => {
const { tree, parser, root } = parse(`
class Server {
public:
void start();
};
void Server::start() {
// implementation
}
`);
const result = extractor.extractStructure(root);
// The class should have start as a method (from both declaration and definition)
expect(result.classes[0].methods).toContain("start");
// The out-of-class definition should appear in functions
const startFn = result.functions.find((f) => f.name === "start");
expect(startFn).toBeDefined();
expect(startFn!.returnType).toBe("void");
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports non-static functions and not static ones", () => {
const { tree, parser, root } = parse(`
int public_fn(int x) { return x; }
static void private_fn() {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("public_fn");
expect(exportNames).not.toContain("private_fn");
tree.delete();
parser.delete();
});
it("reports correct export line numbers", () => {
const { tree, parser, root } = parse(`
struct Point {
int x;
int y;
};
int compute(int n) { return n * 2; }
`);
const result = extractor.extractStructure(root);
const pointExport = result.exports.find((e) => e.name === "Point");
expect(pointExport?.lineNumber).toBe(2);
const computeExport = result.exports.find((e) => e.name === "compute");
expect(computeExport?.lineNumber).toBe(7);
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple function calls", () => {
const { tree, parser, root } = parse(`
void helper(int x) {}
int main() {
helper(42);
}
`);
const result = extractor.extractCallGraph(root);
const mainCalls = result.filter((e) => e.caller === "main");
expect(mainCalls.some((e) => e.callee === "helper")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts multiple calls from one function", () => {
const { tree, parser, root } = parse(`
void foo() {}
void bar() {}
int main() {
foo();
bar();
}
`);
const result = extractor.extractCallGraph(root);
const mainCalls = result.filter((e) => e.caller === "main");
expect(mainCalls).toHaveLength(2);
expect(mainCalls.some((e) => e.callee === "foo")).toBe(true);
expect(mainCalls.some((e) => e.callee === "bar")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts calls inside namespace functions", () => {
const { tree, parser, root } = parse(`
int baz(int x) { return x; }
namespace ns {
void inner() {
baz(42);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result.some((e) => e.caller === "inner" && e.callee === "baz")).toBe(true);
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`
int main() {
foo();
bar();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores calls outside of functions (no caller)", () => {
const { tree, parser, root } = parse(`
int x = compute();
`);
const result = extractor.extractCallGraph(root);
// Top-level initializers have no enclosing function
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("tracks member function calls (field_expression)", () => {
const { tree, parser, root } = parse(`
void process() {
obj.method();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("process");
expect(result[0].callee).toBe("method");
tree.delete();
parser.delete();
});
});
// ---- Comprehensive C++ test ----
describe("comprehensive C++ file", () => {
it("handles the full C++ test scenario from the spec", () => {
const { tree, parser, root } = parse(`#include <iostream>
#include "config.h"
class Server {
public:
std::string host;
int port;
void start();
int getPort() { return port; }
};
void Server::start() {
std::cout << "starting" << std::endl;
}
namespace utils {
int add(int a, int b) {
return a + b;
}
}
`);
const result = extractor.extractStructure(root);
// Imports: 2 includes
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("iostream");
expect(result.imports[1].source).toBe("config.h");
// Classes: Server
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["host", "port"]);
expect(result.classes[0].methods).toContain("start");
expect(result.classes[0].methods).toContain("getPort");
// Functions: getPort (inline), start (out-of-class), add (namespace)
expect(result.functions).toHaveLength(3);
const fnNames = result.functions.map((f) => f.name).sort();
expect(fnNames).toEqual(["add", "getPort", "start"]);
// add() params
const addFn = result.functions.find((f) => f.name === "add");
expect(addFn?.params).toEqual(["a", "b"]);
expect(addFn?.returnType).toBe("int");
// getPort() inline
const getPortFn = result.functions.find((f) => f.name === "getPort");
expect(getPortFn?.params).toEqual([]);
expect(getPortFn?.returnType).toBe("int");
// Exports: Server, start, getPort, add (all non-static/public)
const exportNames = result.exports.map((e) => e.name).sort();
expect(exportNames).toContain("Server");
expect(exportNames).toContain("start");
expect(exportNames).toContain("getPort");
expect(exportNames).toContain("add");
tree.delete();
parser.delete();
});
});
// ---- Comprehensive pure C test ----
describe("comprehensive pure C file", () => {
it("handles pure C code with structs and functions", () => {
const { tree, parser, root } = parse(`#include <stdio.h>
#include "helper.h"
struct Point {
int x;
int y;
};
void print_point(struct Point* p) {
printf("(%d, %d)", p->x, p->y);
}
int main() {
struct Point p = {1, 2};
print_point(&p);
return 0;
}
`);
const result = extractor.extractStructure(root);
// Imports: 2 includes
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("stdio.h");
expect(result.imports[0].specifiers).toEqual(["stdio.h"]);
expect(result.imports[1].source).toBe("helper.h");
// Classes: Point (struct mapped to class)
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Point");
expect(result.classes[0].properties).toEqual(["x", "y"]);
expect(result.classes[0].methods).toEqual([]);
// Functions: print_point and main
expect(result.functions).toHaveLength(2);
const fnNames = result.functions.map((f) => f.name).sort();
expect(fnNames).toEqual(["main", "print_point"]);
// print_point params
const printFn = result.functions.find((f) => f.name === "print_point");
expect(printFn?.params).toEqual(["p"]);
expect(printFn?.returnType).toBe("void");
// main params
const mainFn = result.functions.find((f) => f.name === "main");
expect(mainFn?.params).toEqual([]);
expect(mainFn?.returnType).toBe("int");
// Exports: non-static functions + struct name
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Point");
expect(exportNames).toContain("print_point");
expect(exportNames).toContain("main");
// Call graph
const calls = extractor.extractCallGraph(root);
// print_point calls printf
const printCalls = calls.filter((e) => e.caller === "print_point");
expect(printCalls.some((e) => e.callee === "printf")).toBe(true);
// main calls print_point
const mainCalls = calls.filter((e) => e.caller === "main");
expect(mainCalls.some((e) => e.callee === "print_point")).toBe(true);
tree.delete();
parser.delete();
});
it("handles pure C code without any classes or structs", () => {
const { tree, parser, root } = parse(`
#include <stdlib.h>
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int main() {
int result = factorial(5);
return 0;
}
`);
const result = extractor.extractStructure(root);
// No classes in pure C without structs
expect(result.classes).toHaveLength(0);
// Functions
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("factorial");
expect(result.functions[0].params).toEqual(["n"]);
expect(result.functions[1].name).toBe("main");
// Call graph: factorial is recursive, main calls factorial
const calls = extractor.extractCallGraph(root);
expect(calls.some((e) => e.caller === "factorial" && e.callee === "factorial")).toBe(true);
expect(calls.some((e) => e.caller === "main" && e.callee === "factorial")).toBe(true);
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,665 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { CSharpExtractor } from "../csharp-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + C# grammar once
let Parser: any;
let Language: any;
let csharpLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-c-sharp/tree-sitter-c_sharp.wasm",
);
csharpLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(csharpLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("CSharpExtractor", () => {
const extractor = new CSharpExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["csharp"]);
});
// ---- Methods/Constructors (mapped to functions) ----
describe("extractStructure - functions (methods & constructors)", () => {
it("extracts methods with params and return types", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public string GetName(int id) {
return "";
}
private void Process(string data, int count) {
}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("GetName");
expect(result.functions[0].params).toEqual(["id"]);
expect(result.functions[0].returnType).toBe("string");
expect(result.functions[1].name).toBe("Process");
expect(result.functions[1].params).toEqual(["data", "count"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts constructors", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public Foo(string name, int value) {
this.name = name;
}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Foo");
expect(result.functions[0].params).toEqual(["name", "value"]);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts methods with no params", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public void Run() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Run");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts methods with generic return types", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public List<string> GetItems() {
return null;
}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("GetItems");
expect(result.functions[0].returnType).toBe("List<string>");
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line methods", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public int Calculate(
int a,
int b
) {
int result = a + b;
return result;
}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(3);
expect(result.functions[0].lineRange[1]).toBe(9);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts class with methods, properties, and fields", () => {
const { tree, parser, root } = parse(`namespace App {
public class Server {
private string _host;
private int _port;
public string Address { get; set; }
public void Start() {}
public void Stop() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["_host", "_port", "Address"]);
expect(result.classes[0].methods).toEqual(["Start", "Stop"]);
expect(result.classes[0].lineRange[0]).toBe(2);
tree.delete();
parser.delete();
});
it("extracts empty class", () => {
const { tree, parser, root } = parse(`namespace App {
public class Empty {
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Empty");
expect(result.classes[0].properties).toEqual([]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("includes constructors in methods list", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public Foo() {}
public void Run() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes[0].methods).toEqual(["Foo", "Run"]);
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interface with method signatures", () => {
const { tree, parser, root } = parse(`namespace App {
interface IRepository {
List<User> FindAll();
User FindById(int id);
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("IRepository");
expect(result.classes[0].methods).toEqual(["FindAll", "FindById"]);
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts empty interface", () => {
const { tree, parser, root } = parse(`namespace App {
interface IMarker {
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("IMarker");
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Imports (using directives) ----
describe("extractStructure - imports", () => {
it("extracts simple using directives", () => {
const { tree, parser, root } = parse(`using System;
namespace App {
public class Foo {}
}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("System");
expect(result.imports[0].specifiers).toEqual(["System"]);
expect(result.imports[0].lineNumber).toBe(1);
tree.delete();
parser.delete();
});
it("extracts qualified using directives", () => {
const { tree, parser, root } = parse(`using System;
using System.Collections.Generic;
namespace App {
public class Foo {}
}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("System");
expect(result.imports[0].specifiers).toEqual(["System"]);
expect(result.imports[0].lineNumber).toBe(1);
expect(result.imports[1].source).toBe("System.Collections.Generic");
expect(result.imports[1].specifiers).toEqual(["Generic"]);
expect(result.imports[1].lineNumber).toBe(2);
tree.delete();
parser.delete();
});
it("reports correct import line numbers with gaps", () => {
const { tree, parser, root } = parse(`using System;
using System.Linq;
namespace App {
public class Foo {}
}
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(1);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports public class, methods, constructor, and properties", () => {
const { tree, parser, root } = parse(`namespace App {
public class UserService {
private string _name;
public int MaxRetries { get; set; }
public UserService(string name) {
_name = name;
}
public void Start() {}
private void Helper() {}
}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService"); // class
// Constructor is also named UserService
const userServiceExports = result.exports.filter(
(e) => e.name === "UserService",
);
expect(userServiceExports.length).toBe(2); // class + constructor
expect(exportNames).toContain("MaxRetries"); // public property
expect(exportNames).toContain("Start");
expect(exportNames).not.toContain("Helper");
expect(exportNames).not.toContain("_name"); // private field
tree.delete();
parser.delete();
});
it("does not export non-public classes", () => {
const { tree, parser, root } = parse(`namespace App {
class Internal {
void Run() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(0);
tree.delete();
parser.delete();
});
it("exports public fields", () => {
const { tree, parser, root } = parse(`namespace App {
public class Config {
public string ApiKey;
private int _retries;
}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Config");
expect(exportNames).toContain("ApiKey");
expect(exportNames).not.toContain("_retries");
tree.delete();
parser.delete();
});
it("exports public interface", () => {
const { tree, parser, root } = parse(`namespace App {
public interface IRepository {
void Save();
}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("IRepository");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple method calls", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public void Process(int data) {
Transform(data);
Format(data);
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].caller).toBe("Process");
expect(result[0].callee).toBe("Transform");
expect(result[1].caller).toBe("Process");
expect(result[1].callee).toBe("Format");
tree.delete();
parser.delete();
});
it("extracts qualified method calls (e.g. Console.WriteLine)", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
private void Log(string message) {
Console.WriteLine(message);
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Log");
expect(result[0].callee).toBe("Console.WriteLine");
tree.delete();
parser.delete();
});
it("extracts object creation expressions", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public void Create() {
var b = new Bar();
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Create");
expect(result[0].callee).toBe("new Bar");
tree.delete();
parser.delete();
});
it("tracks correct caller for constructors", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public Foo() {
Init();
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Foo");
expect(result[0].callee).toBe("Init");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
public void Run() {
Foo();
Bar();
}
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(4);
expect(result[1].lineNumber).toBe(5);
tree.delete();
parser.delete();
});
it("ignores calls outside methods (no caller)", () => {
const { tree, parser, root } = parse(`namespace App {
public class Foo {
private string _value = String.Empty;
}
}
`);
const result = extractor.extractCallGraph(root);
// No enclosing method, so these are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
});
// ---- Namespace handling ----
describe("namespace handling", () => {
it("extracts declarations from block-scoped namespace", () => {
const { tree, parser, root } = parse(`namespace App.Services {
public class Svc {
public void Run() {}
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Svc");
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Run");
tree.delete();
parser.delete();
});
it("extracts declarations alongside file-scoped namespace", () => {
const { tree, parser, root } = parse(`namespace App.Services;
public class Svc {
public void Run() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Svc");
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Run");
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive C# file", () => {
it("handles a realistic C# module", () => {
const { tree, parser, root } = parse(`using System;
using System.Collections.Generic;
namespace App.Services
{
public class UserService
{
private string _name;
public int MaxRetries { get; set; }
public UserService(string name)
{
_name = name;
}
public List<User> GetUsers(int limit)
{
return FetchFromDb(limit);
}
private void Log(string message)
{
Console.WriteLine(message);
}
}
public interface IRepository
{
List<User> FindAll();
User FindById(int id);
}
}
`);
const result = extractor.extractStructure(root);
// Functions: UserService (constructor), GetUsers, Log
expect(result.functions).toHaveLength(3);
expect(result.functions.map((f) => f.name).sort()).toEqual(
["GetUsers", "Log", "UserService"].sort(),
);
// Constructor has params but no return type
const ctor = result.functions.find((f) => f.name === "UserService");
expect(ctor?.params).toEqual(["name"]);
expect(ctor?.returnType).toBeUndefined();
// GetUsers has params and generic return type
const getUsers = result.functions.find((f) => f.name === "GetUsers");
expect(getUsers?.params).toEqual(["limit"]);
expect(getUsers?.returnType).toBe("List<User>");
// Log has params and void return type
const log = result.functions.find((f) => f.name === "Log");
expect(log?.params).toEqual(["message"]);
expect(log?.returnType).toBe("void");
// Classes: UserService, IRepository
expect(result.classes).toHaveLength(2);
const userService = result.classes.find(
(c) => c.name === "UserService",
);
expect(userService).toBeDefined();
expect(userService!.methods.sort()).toEqual(
["GetUsers", "Log", "UserService"].sort(),
);
expect(userService!.properties.sort()).toEqual(
["MaxRetries", "_name"].sort(),
);
const repository = result.classes.find(
(c) => c.name === "IRepository",
);
expect(repository).toBeDefined();
expect(repository!.methods).toEqual(["FindAll", "FindById"]);
expect(repository!.properties).toEqual([]);
// Imports: 2 (System, System.Collections.Generic)
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("System");
expect(result.imports[0].specifiers).toEqual(["System"]);
expect(result.imports[1].source).toBe("System.Collections.Generic");
expect(result.imports[1].specifiers).toEqual(["Generic"]);
// Exports: UserService (class + constructor), MaxRetries, GetUsers, IRepository
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("GetUsers");
expect(exportNames).toContain("MaxRetries");
expect(exportNames).toContain("IRepository");
expect(exportNames).not.toContain("Log"); // private
expect(exportNames).not.toContain("_name"); // private field
// Call graph
const calls = extractor.extractCallGraph(root);
const getUsersCalls = calls.filter((e) => e.caller === "GetUsers");
expect(getUsersCalls.some((e) => e.callee === "FetchFromDb")).toBe(
true,
);
const logCalls = calls.filter((e) => e.caller === "Log");
expect(
logCalls.some((e) => e.callee === "Console.WriteLine"),
).toBe(true);
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,599 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { GoExtractor } from "../go-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Go grammar once
let Parser: any;
let Language: any;
let goLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-go/tree-sitter-go.wasm",
);
goLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(goLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("GoExtractor", () => {
const extractor = new GoExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["go"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts functions with params and return types", () => {
const { tree, parser, root } = parse(`package main
func NewServer(host string, port int) *Server {
return nil
}
func helper(x int) string {
return ""
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("NewServer");
expect(result.functions[0].params).toEqual(["host", "port"]);
expect(result.functions[0].returnType).toBe("*Server");
expect(result.functions[0].lineRange[0]).toBe(3);
expect(result.functions[1].name).toBe("helper");
expect(result.functions[1].params).toEqual(["x"]);
expect(result.functions[1].returnType).toBe("string");
tree.delete();
parser.delete();
});
it("extracts functions with no params and no return type", () => {
const { tree, parser, root } = parse(`package main
func noop() {
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts functions with multiple return types", () => {
const { tree, parser, root } = parse(`package main
func divide(a, b float64) (float64, error) {
return 0, nil
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("divide");
expect(result.functions[0].params).toEqual(["a", "b"]);
expect(result.functions[0].returnType).toBe("(float64, error)");
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line functions", () => {
const { tree, parser, root } = parse(`package main
func multiline(
a int,
b int,
) int {
result := a + b
return result
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(3);
expect(result.functions[0].lineRange[1]).toBe(9);
tree.delete();
parser.delete();
});
});
// ---- Methods ----
describe("extractStructure - methods", () => {
it("extracts methods with receivers", () => {
const { tree, parser, root } = parse(`package main
type Server struct {
Host string
}
func (s *Server) Start() error {
return nil
}
func (s Server) Name() string {
return s.Host
}
`);
const result = extractor.extractStructure(root);
// Methods appear in functions list
const methodNames = result.functions.map((f) => f.name);
expect(methodNames).toContain("Start");
expect(methodNames).toContain("Name");
// Methods are also linked to the struct
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].methods).toContain("Start");
expect(result.classes[0].methods).toContain("Name");
// Method return types are extracted
const startFn = result.functions.find((f) => f.name === "Start");
expect(startFn?.returnType).toBe("error");
expect(startFn?.params).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Structs ----
describe("extractStructure - structs", () => {
it("extracts struct with fields", () => {
const { tree, parser, root } = parse(`package main
type Server struct {
Host string
Port int
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["Host", "Port"]);
expect(result.classes[0].methods).toEqual([]);
expect(result.classes[0].lineRange[0]).toBe(3);
tree.delete();
parser.delete();
});
it("extracts empty struct", () => {
const { tree, parser, root } = parse(`package main
type Empty struct{}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Empty");
expect(result.classes[0].properties).toEqual([]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts struct with multiple name fields sharing a type", () => {
const { tree, parser, root } = parse(`package main
type Point struct {
X, Y int
Z float64
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toContain("X");
expect(result.classes[0].properties).toContain("Y");
expect(result.classes[0].properties).toContain("Z");
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interface with method signatures", () => {
const { tree, parser, root } = parse(`package main
type Reader interface {
Read(buf []byte) (int, error)
Close() error
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Reader");
expect(result.classes[0].methods).toEqual(["Read", "Close"]);
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts empty interface", () => {
const { tree, parser, root } = parse(`package main
type Any interface{}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Any");
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts grouped imports", () => {
const { tree, parser, root } = parse(`package main
import (
"fmt"
"os"
)
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("fmt");
expect(result.imports[0].specifiers).toEqual(["fmt"]);
expect(result.imports[1].source).toBe("os");
expect(result.imports[1].specifiers).toEqual(["os"]);
tree.delete();
parser.delete();
});
it("extracts single import", () => {
const { tree, parser, root } = parse(`package main
import "fmt"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("fmt");
expect(result.imports[0].specifiers).toEqual(["fmt"]);
tree.delete();
parser.delete();
});
it("extracts imports with path components", () => {
const { tree, parser, root } = parse(`package main
import "net/http"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("net/http");
expect(result.imports[0].specifiers).toEqual(["http"]);
tree.delete();
parser.delete();
});
it("extracts aliased imports", () => {
const { tree, parser, root } = parse(`package main
import (
f "fmt"
myhttp "net/http"
)
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("fmt");
expect(result.imports[0].specifiers).toEqual(["f"]);
expect(result.imports[1].source).toBe("net/http");
expect(result.imports[1].specifiers).toEqual(["myhttp"]);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`package main
import (
"fmt"
"os"
)
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(4);
expect(result.imports[1].lineNumber).toBe(5);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports uppercase function and type names", () => {
const { tree, parser, root } = parse(`package main
type Server struct {
Host string
Port int
}
func (s *Server) Start() error {
return nil
}
func NewServer(host string, port int) *Server {
return nil
}
func helper(x int) string {
return ""
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Server");
expect(exportNames).toContain("Start");
expect(exportNames).toContain("NewServer");
expect(exportNames).not.toContain("helper");
expect(result.exports).toHaveLength(3);
tree.delete();
parser.delete();
});
it("does not export lowercase names", () => {
const { tree, parser, root } = parse(`package main
type internal struct {
value int
}
func private() {}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(0);
tree.delete();
parser.delete();
});
it("exports uppercase interface names", () => {
const { tree, parser, root } = parse(`package main
type Writer interface {
Write(data []byte) error
}
type reader interface {
read() error
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Writer");
expect(exportNames).not.toContain("reader");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple function calls", () => {
const { tree, parser, root } = parse(`package main
func process(data int) {
transform(data)
formatOutput(data)
}
func main() {
process(42)
}
`);
const result = extractor.extractCallGraph(root);
const processCalls = result.filter((e) => e.caller === "process");
expect(processCalls.some((e) => e.callee === "transform")).toBe(true);
expect(processCalls.some((e) => e.callee === "formatOutput")).toBe(true);
const mainCalls = result.filter((e) => e.caller === "main");
expect(mainCalls.some((e) => e.callee === "process")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts selector expression calls (e.g. fmt.Println)", () => {
const { tree, parser, root } = parse(`package main
import "fmt"
func Start() {
fmt.Println("starting")
}
func helper(x int) string {
return fmt.Sprintf("%d", x)
}
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "Start");
expect(startCalls.some((e) => e.callee === "fmt.Println")).toBe(true);
const helperCalls = result.filter((e) => e.caller === "helper");
expect(helperCalls.some((e) => e.callee === "fmt.Sprintf")).toBe(true);
tree.delete();
parser.delete();
});
it("tracks correct caller for methods", () => {
const { tree, parser, root } = parse(`package main
import "fmt"
func (s *Server) Start() error {
fmt.Println("starting")
return nil
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Start");
expect(result[0].callee).toBe("fmt.Println");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`package main
func main() {
foo()
bar()
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(4);
expect(result[1].lineNumber).toBe(5);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`package main
var _ = fmt.Println("hello")
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing function, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Go file", () => {
it("handles a realistic Go module", () => {
const { tree, parser, root } = parse(`package main
import (
"fmt"
"os"
)
type Server struct {
Host string
Port int
}
func (s *Server) Start() error {
fmt.Println("starting")
return nil
}
func NewServer(host string, port int) *Server {
return &Server{Host: host, Port: port}
}
func helper(x int) string {
return fmt.Sprintf("%d", x)
}
`);
const result = extractor.extractStructure(root);
// Functions: Start, NewServer, helper
expect(result.functions).toHaveLength(3);
expect(result.functions.map((f) => f.name).sort()).toEqual(
["Start", "NewServer", "helper"].sort(),
);
// Struct: Server with properties Host, Port and method Start
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["Host", "Port"]);
expect(result.classes[0].methods).toContain("Start");
// Imports: fmt, os
expect(result.imports).toHaveLength(2);
expect(result.imports.map((i) => i.source).sort()).toEqual(["fmt", "os"]);
// Exports: Server, Start, NewServer (all uppercase)
const exportNames = result.exports.map((e) => e.name).sort();
expect(exportNames).toEqual(["NewServer", "Server", "Start"]);
// Call graph
const calls = extractor.extractCallGraph(root);
const startCalls = calls.filter((e) => e.caller === "Start");
expect(startCalls.some((e) => e.callee === "fmt.Println")).toBe(true);
const helperCalls = calls.filter((e) => e.caller === "helper");
expect(helperCalls.some((e) => e.callee === "fmt.Sprintf")).toBe(true);
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,568 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { JavaExtractor } from "../java-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Java grammar once
let Parser: any;
let Language: any;
let javaLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-java/tree-sitter-java.wasm",
);
javaLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(javaLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("JavaExtractor", () => {
const extractor = new JavaExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["java"]);
});
// ---- Methods/Constructors (mapped to functions) ----
describe("extractStructure - functions (methods & constructors)", () => {
it("extracts methods with params and return types", () => {
const { tree, parser, root } = parse(`public class Foo {
public String getName(int id) {
return "";
}
private void process(String data, int count) {
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("getName");
expect(result.functions[0].params).toEqual(["id"]);
expect(result.functions[0].returnType).toBe("String");
expect(result.functions[1].name).toBe("process");
expect(result.functions[1].params).toEqual(["data", "count"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts constructors", () => {
const { tree, parser, root } = parse(`public class Foo {
public Foo(String name, int value) {
this.name = name;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("Foo");
expect(result.functions[0].params).toEqual(["name", "value"]);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts methods with no params", () => {
const { tree, parser, root } = parse(`public class Foo {
public void run() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("run");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts methods with generic return types", () => {
const { tree, parser, root } = parse(`public class Foo {
public List<String> getItems() {
return null;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("getItems");
expect(result.functions[0].returnType).toBe("List<String>");
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line methods", () => {
const { tree, parser, root } = parse(`public class Foo {
public int calculate(
int a,
int b
) {
int result = a + b;
return result;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts class with methods and fields", () => {
const { tree, parser, root } = parse(`public class Server {
private String host;
private int port;
public void start() {}
public void stop() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Server");
expect(result.classes[0].properties).toEqual(["host", "port"]);
expect(result.classes[0].methods).toEqual(["start", "stop"]);
expect(result.classes[0].lineRange[0]).toBe(1);
tree.delete();
parser.delete();
});
it("extracts empty class", () => {
const { tree, parser, root } = parse(`public class Empty {
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Empty");
expect(result.classes[0].properties).toEqual([]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("includes constructors in methods list", () => {
const { tree, parser, root } = parse(`public class Foo {
public Foo() {}
public void run() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes[0].methods).toEqual(["Foo", "run"]);
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interface with method signatures", () => {
const { tree, parser, root } = parse(`interface Repository {
List<User> findAll();
User findById(int id);
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Repository");
expect(result.classes[0].methods).toEqual(["findAll", "findById"]);
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts empty interface", () => {
const { tree, parser, root } = parse(`interface Marker {
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Marker");
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts regular imports", () => {
const { tree, parser, root } = parse(`import java.util.List;
import java.util.Map;
public class Foo {}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("java.util.List");
expect(result.imports[0].specifiers).toEqual(["List"]);
expect(result.imports[0].lineNumber).toBe(1);
expect(result.imports[1].source).toBe("java.util.Map");
expect(result.imports[1].specifiers).toEqual(["Map"]);
expect(result.imports[1].lineNumber).toBe(2);
tree.delete();
parser.delete();
});
it("extracts wildcard imports", () => {
const { tree, parser, root } = parse(`import java.util.*;
public class Foo {}
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("java.util");
expect(result.imports[0].specifiers).toEqual(["*"]);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`import java.util.List;
import java.util.Map;
public class Foo {}
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(1);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports public class, methods, and constructor", () => {
const { tree, parser, root } = parse(`public class UserService {
private String name;
public UserService(String name) {
this.name = name;
}
public void start() {}
private void helper() {}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService"); // class
// The constructor is also named UserService, check it's listed
const userServiceExports = result.exports.filter(
(e) => e.name === "UserService",
);
expect(userServiceExports.length).toBe(2); // class + constructor
expect(exportNames).toContain("start");
expect(exportNames).not.toContain("helper");
expect(exportNames).not.toContain("name"); // private field
tree.delete();
parser.delete();
});
it("does not export non-public classes", () => {
const { tree, parser, root } = parse(`class Internal {
void run() {}
}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(0);
tree.delete();
parser.delete();
});
it("exports public fields", () => {
const { tree, parser, root } = parse(`public class Config {
public String apiKey;
private int retries;
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Config");
expect(exportNames).toContain("apiKey");
expect(exportNames).not.toContain("retries");
tree.delete();
parser.delete();
});
it("exports public interface", () => {
const { tree, parser, root } = parse(`public interface Repository {
void save();
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Repository");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple method calls", () => {
const { tree, parser, root } = parse(`public class Foo {
public void process(int data) {
transform(data);
format(data);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].caller).toBe("process");
expect(result[0].callee).toBe("transform");
expect(result[1].caller).toBe("process");
expect(result[1].callee).toBe("format");
tree.delete();
parser.delete();
});
it("extracts qualified method calls (e.g. System.out.println)", () => {
const { tree, parser, root } = parse(`public class Foo {
private void log(String message) {
System.out.println(message);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("log");
expect(result[0].callee).toBe("System.out.println");
tree.delete();
parser.delete();
});
it("extracts object creation expressions", () => {
const { tree, parser, root } = parse(`public class Foo {
public void create() {
Bar b = new Bar();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("create");
expect(result[0].callee).toBe("new Bar");
tree.delete();
parser.delete();
});
it("tracks correct caller for constructors", () => {
const { tree, parser, root } = parse(`public class Foo {
public Foo() {
init();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("Foo");
expect(result[0].callee).toBe("init");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`public class Foo {
public void run() {
foo();
bar();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores calls outside methods (no caller)", () => {
// Java doesn't really allow top-level calls, but field initializers
// can have method calls. We skip those without a method context.
const { tree, parser, root } = parse(`public class Foo {
private String value = String.valueOf(42);
}
`);
const result = extractor.extractCallGraph(root);
// No enclosing method, so these are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Java file", () => {
it("handles a realistic Java module", () => {
const { tree, parser, root } = parse(`import java.util.List;
import java.util.Map;
public class UserService {
private String name;
private int maxRetries;
public UserService(String name) {
this.name = name;
}
public List<User> getUsers(int limit) {
return fetchFromDb(limit);
}
private void log(String message) {
System.out.println(message);
}
}
interface Repository {
List<User> findAll();
User findById(int id);
}
`);
const result = extractor.extractStructure(root);
// Functions: UserService (constructor), getUsers, log
expect(result.functions).toHaveLength(3);
expect(result.functions.map((f) => f.name).sort()).toEqual(
["UserService", "getUsers", "log"].sort(),
);
// Constructor has params but no return type
const ctor = result.functions.find((f) => f.name === "UserService");
expect(ctor?.params).toEqual(["name"]);
expect(ctor?.returnType).toBeUndefined();
// getUsers has params and generic return type
const getUsers = result.functions.find((f) => f.name === "getUsers");
expect(getUsers?.params).toEqual(["limit"]);
expect(getUsers?.returnType).toBe("List<User>");
// log has params and void return type
const log = result.functions.find((f) => f.name === "log");
expect(log?.params).toEqual(["message"]);
expect(log?.returnType).toBe("void");
// Classes: UserService, Repository
expect(result.classes).toHaveLength(2);
const userService = result.classes.find(
(c) => c.name === "UserService",
);
expect(userService).toBeDefined();
expect(userService!.methods.sort()).toEqual(
["UserService", "getUsers", "log"].sort(),
);
expect(userService!.properties.sort()).toEqual(
["name", "maxRetries"].sort(),
);
const repository = result.classes.find(
(c) => c.name === "Repository",
);
expect(repository).toBeDefined();
expect(repository!.methods).toEqual(["findAll", "findById"]);
expect(repository!.properties).toEqual([]);
// Imports: 2 (java.util.List, java.util.Map)
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("java.util.List");
expect(result.imports[0].specifiers).toEqual(["List"]);
expect(result.imports[1].source).toBe("java.util.Map");
expect(result.imports[1].specifiers).toEqual(["Map"]);
// Exports: UserService (class), UserService (constructor), getUsers (public method)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("getUsers");
expect(exportNames).not.toContain("log"); // private
expect(exportNames).not.toContain("name"); // private field
expect(exportNames).not.toContain("maxRetries"); // private field
// Call graph
const calls = extractor.extractCallGraph(root);
const getUsersCalls = calls.filter((e) => e.caller === "getUsers");
expect(getUsersCalls.some((e) => e.callee === "fetchFromDb")).toBe(
true,
);
const logCalls = calls.filter((e) => e.caller === "log");
expect(
logCalls.some((e) => e.callee === "System.out.println"),
).toBe(true);
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,604 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { PhpExtractor } from "../php-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + PHP grammar once
let Parser: any;
let Language: any;
let phpLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-php/tree-sitter-php.wasm",
);
phpLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(phpLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("PhpExtractor", () => {
const extractor = new PhpExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["php"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts top-level functions with params and return types", () => {
const { tree, parser, root } = parse(`<?php
function helper(string $x): string {
return strtoupper($x);
}
function greet(string $name, int $times): void {
echo $name;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("helper");
expect(result.functions[0].params).toEqual(["$x"]);
expect(result.functions[0].returnType).toBe("string");
expect(result.functions[1].name).toBe("greet");
expect(result.functions[1].params).toEqual(["$name", "$times"]);
expect(result.functions[1].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("extracts functions without return type", () => {
const { tree, parser, root } = parse(`<?php
function noReturn($x) {
echo $x;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noReturn");
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts functions with no parameters", () => {
const { tree, parser, root } = parse(`<?php
function noop(): void {
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBe("void");
tree.delete();
parser.delete();
});
it("reports correct line ranges", () => {
const { tree, parser, root } = parse(`<?php
function multiline(
string $a,
string $b
): string {
$result = $a . $b;
return $result;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts classes with methods and properties", () => {
const { tree, parser, root } = parse(`<?php
class UserService {
private string $name;
protected int $maxRetries;
public function __construct(string $name) {
$this->name = $name;
}
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
private function log(string $message): void {
error_log($message);
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("UserService");
expect(result.classes[0].methods).toContain("__construct");
expect(result.classes[0].methods).toContain("getUser");
expect(result.classes[0].methods).toContain("log");
expect(result.classes[0].methods).toHaveLength(3);
expect(result.classes[0].properties).toContain("name");
expect(result.classes[0].properties).toContain("maxRetries");
expect(result.classes[0].properties).toHaveLength(2);
tree.delete();
parser.delete();
});
it("also adds class methods to the functions array", () => {
const { tree, parser, root } = parse(`<?php
class Svc {
public function run(string $x): string {
return $x;
}
}
`);
const result = extractor.extractStructure(root);
expect(result.functions.some((f) => f.name === "run")).toBe(true);
expect(result.functions[0].params).toEqual(["$x"]);
expect(result.functions[0].returnType).toBe("string");
tree.delete();
parser.delete();
});
it("extracts classes with static methods", () => {
const { tree, parser, root } = parse(`<?php
class Factory {
public static function create(): self {
return new self();
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].methods).toContain("create");
tree.delete();
parser.delete();
});
it("extracts nullable and optional type properties", () => {
const { tree, parser, root } = parse(`<?php
class Config {
public ?string $nullable;
public static int $counter = 0;
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toContain("nullable");
expect(result.classes[0].properties).toContain("counter");
tree.delete();
parser.delete();
});
it("reports correct class line ranges", () => {
const { tree, parser, root } = parse(`<?php
class MyClass {
public function a(): void {}
public function b(): void {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].lineRange[0]).toBe(2);
expect(result.classes[0].lineRange[1]).toBe(5);
tree.delete();
parser.delete();
});
});
// ---- Interfaces ----
describe("extractStructure - interfaces", () => {
it("extracts interfaces with method signatures", () => {
const { tree, parser, root } = parse(`<?php
interface Loggable {
public function log(string $msg): void;
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Loggable");
expect(result.classes[0].methods).toContain("log");
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("treats interfaces as exports", () => {
const { tree, parser, root } = parse(`<?php
interface Repository {
public function find(int $id): mixed;
public function save(object $entity): void;
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Repository");
tree.delete();
parser.delete();
});
});
// ---- Imports (use statements) ----
describe("extractStructure - imports", () => {
it("extracts simple use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
use App\\Contracts\\Repository;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("App\\Models\\User");
expect(result.imports[0].specifiers).toEqual(["User"]);
expect(result.imports[1].source).toBe("App\\Contracts\\Repository");
expect(result.imports[1].specifiers).toEqual(["Repository"]);
tree.delete();
parser.delete();
});
it("extracts grouped use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\{User, Post};
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].specifiers).toEqual(["User", "Post"]);
tree.delete();
parser.delete();
});
it("extracts aliased use statements", () => {
const { tree, parser, root } = parse(`<?php
use App\\Contracts\\Repository as Repo;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("App\\Contracts\\Repository");
expect(result.imports[0].specifiers).toEqual(["Repository"]);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
use App\\Models\\Post;
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("treats top-level functions as exports", () => {
const { tree, parser, root } = parse(`<?php
function publicFunc(): void {}
function anotherFunc(string $x): string { return $x; }
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("publicFunc");
expect(exportNames).toContain("anotherFunc");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats classes as exports", () => {
const { tree, parser, root } = parse(`<?php
class MyService {}
class MyModel {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("MyService");
expect(exportNames).toContain("MyModel");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("does not treat use statements as exports", () => {
const { tree, parser, root } = parse(`<?php
use App\\Models\\User;
function myFunc(): void {}
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(1);
expect(result.exports[0].name).toBe("myFunc");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts standalone function calls", () => {
const { tree, parser, root } = parse(`<?php
function process(string $data): string {
$result = transform($data);
return format_output($result);
}
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(result.every((e) => e.caller === "process")).toBe(true);
expect(callees).toContain("transform");
expect(callees).toContain("format_output");
tree.delete();
parser.delete();
});
it("extracts instance method calls ($this->method())", () => {
const { tree, parser, root } = parse(`<?php
class Svc {
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result.some((e) => e.caller === "getUser" && e.callee === "$this->fetchFromDb")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts static method calls (Class::method())", () => {
const { tree, parser, root } = parse(`<?php
class Foo {
public function doWork(): void {
$result = Bar::staticMethod();
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result.some((e) => e.caller === "doWork" && e.callee === "Bar::staticMethod")).toBe(true);
tree.delete();
parser.delete();
});
it("tracks correct caller context across nested calls", () => {
const { tree, parser, root } = parse(`<?php
class Service {
public function start(): void {
$this->setup();
run_server();
}
private function setup(): void {
init_config();
}
}
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "start");
expect(startCalls.some((e) => e.callee === "$this->setup")).toBe(true);
expect(startCalls.some((e) => e.callee === "run_server")).toBe(true);
const setupCalls = result.filter((e) => e.caller === "setup");
expect(setupCalls.some((e) => e.callee === "init_config")).toBe(true);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`<?php
echo "hello";
main();
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing function, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`<?php
function main(): void {
foo();
bar();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive PHP file", () => {
it("handles the full test fixture", () => {
const { tree, parser, root } = parse(`<?php
namespace App\\Services;
use App\\Models\\User;
use App\\Contracts\\Repository;
class UserService {
private string $name;
protected int $maxRetries;
public function __construct(string $name) {
$this->name = $name;
}
public function getUser(int $id): User {
return $this->fetchFromDb($id);
}
private function log(string $message): void {
error_log($message);
}
}
function helper(string $x): string {
return strtoupper($x);
}
`);
const result = extractor.extractStructure(root);
// Functions: __construct, getUser, log (from class), helper (top-level)
const funcNames = result.functions.map((f) => f.name);
expect(funcNames).toContain("__construct");
expect(funcNames).toContain("getUser");
expect(funcNames).toContain("log");
expect(funcNames).toContain("helper");
expect(result.functions).toHaveLength(4);
// Classes: UserService
expect(result.classes).toHaveLength(1);
const userService = result.classes[0];
expect(userService.name).toBe("UserService");
expect(userService.methods).toContain("__construct");
expect(userService.methods).toContain("getUser");
expect(userService.methods).toContain("log");
expect(userService.properties).toContain("name");
expect(userService.properties).toContain("maxRetries");
// Imports: 2 use statements
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("App\\Models\\User");
expect(result.imports[0].specifiers).toEqual(["User"]);
expect(result.imports[1].source).toBe("App\\Contracts\\Repository");
expect(result.imports[1].specifiers).toEqual(["Repository"]);
// Exports: UserService (class) + helper (function)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("helper");
expect(result.exports).toHaveLength(2);
// Return types
const getUser = result.functions.find((f) => f.name === "getUser");
expect(getUser).toBeDefined();
expect(getUser!.returnType).toBe("User");
const log = result.functions.find((f) => f.name === "log");
expect(log).toBeDefined();
expect(log!.returnType).toBe("void");
const helper = result.functions.find((f) => f.name === "helper");
expect(helper).toBeDefined();
expect(helper!.returnType).toBe("string");
// Call graph
const calls = extractor.extractCallGraph(root);
// getUser -> $this->fetchFromDb
const getUserCalls = calls.filter((e) => e.caller === "getUser");
expect(getUserCalls.some((e) => e.callee === "$this->fetchFromDb")).toBe(true);
// log -> error_log
const logCalls = calls.filter((e) => e.caller === "log");
expect(logCalls.some((e) => e.callee === "error_log")).toBe(true);
// helper -> strtoupper
const helperCalls = calls.filter((e) => e.caller === "helper");
expect(helperCalls.some((e) => e.callee === "strtoupper")).toBe(true);
tree.delete();
parser.delete();
});
});
// ---- Nullable return types ----
describe("nullable return types", () => {
it("extracts nullable return type", () => {
const { tree, parser, root } = parse(`<?php
function findUser(int $id): ?User {
return null;
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].returnType).toBe("?User");
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,659 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { PythonExtractor } from "../python-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Python grammar once
let Parser: any;
let Language: any;
let pythonLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-python/tree-sitter-python.wasm",
);
pythonLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(pythonLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("PythonExtractor", () => {
const extractor = new PythonExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["python"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts simple functions with type annotations", () => {
const { tree, parser, root } = parse(`
def hello(name: str) -> str:
return f"Hello {name}"
def add(a: int, b: int) -> int:
return a + b
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("hello");
expect(result.functions[0].params).toEqual(["name"]);
expect(result.functions[0].returnType).toBe("str");
expect(result.functions[0].lineRange[0]).toBeGreaterThan(0);
expect(result.functions[1].name).toBe("add");
expect(result.functions[1].params).toEqual(["a", "b"]);
expect(result.functions[1].returnType).toBe("int");
tree.delete();
parser.delete();
});
it("extracts functions without type annotations", () => {
const { tree, parser, root } = parse(`
def greet(name):
print(name)
def noop():
pass
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("greet");
expect(result.functions[0].params).toEqual(["name"]);
expect(result.functions[0].returnType).toBeUndefined();
expect(result.functions[1].name).toBe("noop");
expect(result.functions[1].params).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts functions with default parameters", () => {
const { tree, parser, root } = parse(`
def connect(host: str, port: int = 8080, timeout: float = 30.0):
pass
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("connect");
expect(result.functions[0].params).toEqual(["host", "port", "timeout"]);
tree.delete();
parser.delete();
});
it("extracts functions with *args and **kwargs", () => {
const { tree, parser, root } = parse(`
def flexible(*args, **kwargs):
pass
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].params).toEqual(["*args", "**kwargs"]);
tree.delete();
parser.delete();
});
it("extracts decorated functions", () => {
const { tree, parser, root } = parse(`
@decorator
def decorated_func():
pass
@app.route("/api")
def api_handler():
pass
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("decorated_func");
expect(result.functions[1].name).toBe("api_handler");
tree.delete();
parser.delete();
});
it("reports correct line ranges", () => {
const { tree, parser, root } = parse(`
def multiline(
a: int,
b: int,
) -> int:
result = a + b
return result
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(7);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts classes with methods and properties", () => {
const { tree, parser, root } = parse(`
class DataProcessor:
name: str
def __init__(self, name: str):
self.name = name
def process(self, data: list) -> dict:
return transform(data)
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("DataProcessor");
expect(result.classes[0].methods).toContain("__init__");
expect(result.classes[0].methods).toContain("process");
expect(result.classes[0].properties).toContain("name");
tree.delete();
parser.delete();
});
it("extracts dataclass-style annotated properties", () => {
const { tree, parser, root } = parse(`
class Config:
name: str
value: int
debug: bool
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toEqual(["name", "value", "debug"]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts decorated classes", () => {
const { tree, parser, root } = parse(`
@dataclass
class Config:
name: str
value: int = 0
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Config");
expect(result.classes[0].properties).toContain("name");
expect(result.classes[0].properties).toContain("value");
tree.delete();
parser.delete();
});
it("extracts decorated methods within a class", () => {
const { tree, parser, root } = parse(`
class MyClass:
@staticmethod
def static_method():
pass
@classmethod
def class_method(cls):
pass
@property
def prop(self):
return self._prop
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].methods).toContain("static_method");
expect(result.classes[0].methods).toContain("class_method");
expect(result.classes[0].methods).toContain("prop");
tree.delete();
parser.delete();
});
it("filters self and cls from method params", () => {
const { tree, parser, root } = parse(`
class Foo:
def instance_method(self, x: int):
pass
@classmethod
def class_method(cls, y: str):
pass
`);
const result = extractor.extractStructure(root);
// Methods are on the class, but top-level functions should not include them
expect(result.functions).toHaveLength(0);
expect(result.classes[0].methods).toEqual(["instance_method", "class_method"]);
tree.delete();
parser.delete();
});
it("reports correct class line ranges", () => {
const { tree, parser, root } = parse(`
class MyClass:
def method_a(self):
pass
def method_b(self):
pass
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].lineRange[0]).toBe(2);
expect(result.classes[0].lineRange[1]).toBe(7);
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts simple import statements", () => {
const { tree, parser, root } = parse(`
import os
import sys
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("os");
expect(result.imports[0].specifiers).toEqual(["os"]);
expect(result.imports[1].source).toBe("sys");
expect(result.imports[1].specifiers).toEqual(["sys"]);
tree.delete();
parser.delete();
});
it("extracts from-import statements", () => {
const { tree, parser, root } = parse(`
from pathlib import Path
from typing import Optional, List
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("pathlib");
expect(result.imports[0].specifiers).toEqual(["Path"]);
expect(result.imports[1].source).toBe("typing");
expect(result.imports[1].specifiers).toEqual(["Optional", "List"]);
tree.delete();
parser.delete();
});
it("extracts aliased imports", () => {
const { tree, parser, root } = parse(`
from foo import bar as baz
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("foo");
expect(result.imports[0].specifiers).toEqual(["baz"]);
tree.delete();
parser.delete();
});
it("extracts dotted module imports", () => {
const { tree, parser, root } = parse(`
import os.path
from os.path import join, exists
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("os.path");
expect(result.imports[0].specifiers).toEqual(["os.path"]);
expect(result.imports[1].source).toBe("os.path");
expect(result.imports[1].specifiers).toEqual(["join", "exists"]);
tree.delete();
parser.delete();
});
it("extracts wildcard imports", () => {
const { tree, parser, root } = parse(`
from os.path import *
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("os.path");
expect(result.imports[0].specifiers).toEqual(["*"]);
tree.delete();
parser.delete();
});
it("handles all import types together", () => {
const { tree, parser, root } = parse(`
import os
from pathlib import Path
from typing import Optional, List
`);
const result = extractor.extractStructure(root);
expect(result.imports.length).toBeGreaterThanOrEqual(3);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`
import os
from pathlib import Path
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("treats top-level functions as exports", () => {
const { tree, parser, root } = parse(`
def public_func():
pass
def another_func(x: int) -> str:
return str(x)
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("public_func");
expect(exportNames).toContain("another_func");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats top-level classes as exports", () => {
const { tree, parser, root } = parse(`
class MyService:
pass
class MyModel:
pass
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("MyService");
expect(exportNames).toContain("MyModel");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats decorated top-level definitions as exports", () => {
const { tree, parser, root } = parse(`
@dataclass
class Config:
name: str
@app.route("/")
def index():
pass
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Config");
expect(exportNames).toContain("index");
tree.delete();
parser.delete();
});
it("does not treat imports as exports", () => {
const { tree, parser, root } = parse(`
import os
from pathlib import Path
def my_func():
pass
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(1);
expect(result.exports[0].name).toBe("my_func");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple function calls", () => {
const { tree, parser, root } = parse(`
def process(data):
result = transform(data)
return format_output(result)
def main():
process([1, 2, 3])
`);
const result = extractor.extractCallGraph(root);
expect(result.length).toBeGreaterThanOrEqual(2);
const processCallers = result.filter((e) => e.caller === "process");
expect(processCallers.some((e) => e.callee === "transform")).toBe(true);
expect(processCallers.some((e) => e.callee === "format_output")).toBe(true);
const mainCallers = result.filter((e) => e.caller === "main");
expect(mainCallers.some((e) => e.callee === "process")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts attribute-based calls (method calls)", () => {
const { tree, parser, root } = parse(`
def process():
self.method()
os.path.join("a", "b")
result.save()
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(callees).toContain("self.method");
expect(callees).toContain("os.path.join");
expect(callees).toContain("result.save");
tree.delete();
parser.delete();
});
it("tracks correct caller context for nested calls", () => {
const { tree, parser, root } = parse(`
def outer():
helper()
def inner():
deep_call()
another()
`);
const result = extractor.extractCallGraph(root);
const outerCalls = result.filter((e) => e.caller === "outer");
expect(outerCalls.some((e) => e.callee === "helper")).toBe(true);
expect(outerCalls.some((e) => e.callee === "another")).toBe(true);
const innerCalls = result.filter((e) => e.caller === "inner");
expect(innerCalls.some((e) => e.callee === "deep_call")).toBe(true);
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`
def main():
foo()
bar()
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`
print("hello")
main()
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing function, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("handles calls inside class methods", () => {
const { tree, parser, root } = parse(`
class Service:
def start(self):
self.setup()
run_server()
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "start");
expect(startCalls.some((e) => e.callee === "self.setup")).toBe(true);
expect(startCalls.some((e) => e.callee === "run_server")).toBe(true);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Python file", () => {
it("handles a realistic Python module", () => {
const { tree, parser, root } = parse(`
import os
from pathlib import Path
from typing import Optional, List
class FileProcessor:
name: str
verbose: bool
def __init__(self, name: str, verbose: bool = False):
self.name = name
self.verbose = verbose
def process(self, paths: List[str]) -> dict:
results = {}
for p in paths:
results[p] = self._read_file(p)
return results
def _read_file(self, path: str) -> Optional[str]:
full = Path(path)
if full.exists():
return full.read_text()
return None
def create_processor(name: str) -> FileProcessor:
return FileProcessor(name)
@staticmethod
def utility_func(*args, **kwargs) -> None:
print(args, kwargs)
`);
const result = extractor.extractStructure(root);
// Imports
expect(result.imports.length).toBeGreaterThanOrEqual(3);
// Class
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("FileProcessor");
expect(result.classes[0].methods).toContain("__init__");
expect(result.classes[0].methods).toContain("process");
expect(result.classes[0].methods).toContain("_read_file");
expect(result.classes[0].properties).toContain("name");
expect(result.classes[0].properties).toContain("verbose");
// Top-level functions
expect(result.functions.some((f) => f.name === "create_processor")).toBe(
true,
);
expect(result.functions.some((f) => f.name === "utility_func")).toBe(
true,
);
// Exports (top-level defs)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("FileProcessor");
expect(exportNames).toContain("create_processor");
expect(exportNames).toContain("utility_func");
// Call graph
const calls = extractor.extractCallGraph(root);
expect(calls.length).toBeGreaterThan(0);
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,688 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { RubyExtractor } from "../ruby-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Ruby grammar once
let Parser: any;
let Language: any;
let rubyLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-ruby/tree-sitter-ruby.wasm",
);
rubyLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(rubyLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("RubyExtractor", () => {
const extractor = new RubyExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["ruby"]);
});
// ---- Functions / Methods ----
describe("extractStructure - functions", () => {
it("extracts simple methods", () => {
const { tree, parser, root } = parse(`
def hello(name)
puts name
end
def add(a, b)
a + b
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("hello");
expect(result.functions[0].params).toEqual(["name"]);
expect(result.functions[0].lineRange[0]).toBeGreaterThan(0);
expect(result.functions[1].name).toBe("add");
expect(result.functions[1].params).toEqual(["a", "b"]);
tree.delete();
parser.delete();
});
it("extracts methods with optional parameters", () => {
const { tree, parser, root } = parse(`
def connect(host, port = 8080, timeout = 30.0)
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("connect");
expect(result.functions[0].params).toEqual(["host", "port", "timeout"]);
tree.delete();
parser.delete();
});
it("extracts methods with splat, hash splat, and block parameters", () => {
const { tree, parser, root } = parse(`
def flexible(*args, **kwargs, &block)
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].params).toEqual(["*args", "**kwargs", "&block"]);
tree.delete();
parser.delete();
});
it("extracts methods with no parameters", () => {
const { tree, parser, root } = parse(`
def noop
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
tree.delete();
parser.delete();
});
it("does not assign return types (Ruby has none)", () => {
const { tree, parser, root } = parse(`
def compute(x)
x * 2
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("reports correct line ranges", () => {
const { tree, parser, root } = parse(`
def multiline(
a,
b
)
result = a + b
result
end
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Classes ----
describe("extractStructure - classes", () => {
it("extracts classes with methods", () => {
const { tree, parser, root } = parse(`
class UserService
def initialize(name)
@name = name
end
def find_user(id)
db_query(id)
end
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("UserService");
expect(result.classes[0].methods).toContain("initialize");
expect(result.classes[0].methods).toContain("find_user");
tree.delete();
parser.delete();
});
it("extracts attr_accessor, attr_reader, attr_writer as properties", () => {
const { tree, parser, root } = parse(`
class Model
attr_accessor :name, :email
attr_reader :id
attr_writer :status
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toEqual(["name", "email", "id", "status"]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts singleton methods (def self.foo) within classes", () => {
const { tree, parser, root } = parse(`
class Factory
def self.create(attrs)
new(attrs)
end
def instance_method
end
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].methods).toContain("self.create");
expect(result.classes[0].methods).toContain("instance_method");
tree.delete();
parser.delete();
});
it("also adds class methods to the functions array", () => {
const { tree, parser, root } = parse(`
class Svc
def run(x)
x
end
end
`);
const result = extractor.extractStructure(root);
// Class methods appear in the top-level functions array
expect(result.functions.some((f) => f.name === "run")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts namespaced class names", () => {
const { tree, parser, root } = parse(`
class Foo::Bar
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Foo::Bar");
tree.delete();
parser.delete();
});
it("reports correct class line ranges", () => {
const { tree, parser, root } = parse(`
class MyClass
def method_a
end
def method_b
end
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].lineRange[0]).toBe(2);
expect(result.classes[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Modules ----
describe("extractStructure - modules", () => {
it("treats modules as classes", () => {
const { tree, parser, root } = parse(`
module Helpers
def format_date(date)
date.strftime("%Y-%m-%d")
end
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Helpers");
expect(result.classes[0].methods).toContain("format_date");
tree.delete();
parser.delete();
});
it("extracts module properties from attr_* calls", () => {
const { tree, parser, root } = parse(`
module Config
attr_accessor :debug
end
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].properties).toContain("debug");
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts require statements", () => {
const { tree, parser, root } = parse(`
require "json"
require "net/http"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("json");
expect(result.imports[0].specifiers).toEqual(["json"]);
expect(result.imports[1].source).toBe("net/http");
expect(result.imports[1].specifiers).toEqual(["net/http"]);
tree.delete();
parser.delete();
});
it("extracts require_relative statements", () => {
const { tree, parser, root } = parse(`
require_relative "./helper"
require_relative "../lib/utils"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("./helper");
expect(result.imports[1].source).toBe("../lib/utils");
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`
require "json"
require_relative "./helper"
`);
const result = extractor.extractStructure(root);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
it("handles mixed require and require_relative", () => {
const { tree, parser, root } = parse(`
require "json"
require_relative "./helper"
require "yaml"
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(3);
expect(result.imports[0].source).toBe("json");
expect(result.imports[1].source).toBe("./helper");
expect(result.imports[2].source).toBe("yaml");
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("treats top-level methods as exports", () => {
const { tree, parser, root } = parse(`
def public_func
end
def another_func(x)
end
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("public_func");
expect(exportNames).toContain("another_func");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats top-level classes as exports", () => {
const { tree, parser, root } = parse(`
class MyService
end
class MyModel
end
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("MyService");
expect(exportNames).toContain("MyModel");
expect(result.exports).toHaveLength(2);
tree.delete();
parser.delete();
});
it("treats top-level modules as exports", () => {
const { tree, parser, root } = parse(`
module Helpers
end
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Helpers");
tree.delete();
parser.delete();
});
it("does not treat imports as exports", () => {
const { tree, parser, root } = parse(`
require "json"
require_relative "./helper"
def my_func
end
`);
const result = extractor.extractStructure(root);
expect(result.exports).toHaveLength(1);
expect(result.exports[0].name).toBe("my_func");
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple method calls", () => {
const { tree, parser, root } = parse(`
def process(data)
result = transform(data)
format_output(result)
end
def main
process([1, 2, 3])
end
`);
const result = extractor.extractCallGraph(root);
const processCallers = result.filter((e) => e.caller === "process");
expect(processCallers.some((e) => e.callee === "transform")).toBe(true);
expect(processCallers.some((e) => e.callee === "format_output")).toBe(true);
const mainCallers = result.filter((e) => e.caller === "main");
expect(mainCallers.some((e) => e.callee === "process")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts receiver-based calls (method calls on objects)", () => {
const { tree, parser, root } = parse(`
def process
result.save
date.strftime("%Y-%m-%d")
end
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(callees).toContain("result.save");
expect(callees).toContain("date.strftime");
tree.delete();
parser.delete();
});
it("tracks correct caller context for calls inside class methods", () => {
const { tree, parser, root } = parse(`
class Service
def start
setup
run_server
end
end
`);
const result = extractor.extractCallGraph(root);
const startCalls = result.filter((e) => e.caller === "start");
expect(startCalls.some((e) => e.callee === "setup")).toBe(true);
expect(startCalls.some((e) => e.callee === "run_server")).toBe(true);
tree.delete();
parser.delete();
});
it("does not include require/require_relative in call graph", () => {
const { tree, parser, root } = parse(`
def setup
require "json"
do_work
end
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(callees).not.toContain("require");
expect(callees).toContain("do_work");
tree.delete();
parser.delete();
});
it("does not include attr_* macros in call graph", () => {
const { tree, parser, root } = parse(`
class Foo
attr_accessor :bar
def init
setup
end
end
`);
const result = extractor.extractCallGraph(root);
const callees = result.map((e) => e.callee);
expect(callees).not.toContain("attr_accessor");
expect(callees).toContain("setup");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`
def main
foo
bar
end
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`
puts "hello"
main
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing method, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
it("tracks singleton method callers with self. prefix", () => {
const { tree, parser, root } = parse(`
class Foo
def self.create(attrs)
new(attrs)
end
end
`);
const result = extractor.extractCallGraph(root);
const createCalls = result.filter((e) => e.caller === "self.create");
expect(createCalls.some((e) => e.callee === "new")).toBe(true);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Ruby file", () => {
it("handles the full test fixture", () => {
const { tree, parser, root } = parse(`
require "json"
require_relative "./helper"
class UserService
attr_accessor :name, :email
attr_reader :id
def initialize(name, email)
@name = name
@email = email
end
def find_user(id)
result = db_query(id)
format_user(result)
end
def self.create(attrs)
new(attrs[:name], attrs[:email])
end
end
module Helpers
def format_date(date)
date.strftime("%Y-%m-%d")
end
end
def standalone_helper(x)
puts x.to_s
end
`);
const result = extractor.extractStructure(root);
// Functions: initialize, find_user, self.create, format_date, standalone_helper
const funcNames = result.functions.map((f) => f.name);
expect(funcNames).toContain("initialize");
expect(funcNames).toContain("find_user");
expect(funcNames).toContain("self.create");
expect(funcNames).toContain("format_date");
expect(funcNames).toContain("standalone_helper");
expect(result.functions).toHaveLength(5);
// Classes: UserService (methods: initialize, find_user, self.create; properties: name, email, id)
expect(result.classes).toHaveLength(2);
const userService = result.classes.find((c) => c.name === "UserService");
expect(userService).toBeDefined();
expect(userService!.methods).toContain("initialize");
expect(userService!.methods).toContain("find_user");
expect(userService!.methods).toContain("self.create");
expect(userService!.properties).toEqual(
expect.arrayContaining(["name", "email", "id"]),
);
// Helpers module (methods: format_date)
const helpers = result.classes.find((c) => c.name === "Helpers");
expect(helpers).toBeDefined();
expect(helpers!.methods).toContain("format_date");
// Imports: 2 (json, ./helper)
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("json");
expect(result.imports[1].source).toBe("./helper");
// Exports: UserService, Helpers, standalone_helper (all top-level)
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("UserService");
expect(exportNames).toContain("Helpers");
expect(exportNames).toContain("standalone_helper");
// Call graph
const calls = extractor.extractCallGraph(root);
// find_user -> db_query, find_user -> format_user
const findUserCalls = calls.filter((e) => e.caller === "find_user");
expect(findUserCalls.some((e) => e.callee === "db_query")).toBe(true);
expect(findUserCalls.some((e) => e.callee === "format_user")).toBe(true);
// standalone_helper -> puts
const standaloneHelperCalls = calls.filter(
(e) => e.caller === "standalone_helper",
);
expect(standaloneHelperCalls.some((e) => e.callee === "puts")).toBe(true);
// Verify require/require_relative not in call graph
const allCallees = calls.map((e) => e.callee);
expect(allCallees).not.toContain("require");
expect(allCallees).not.toContain("require_relative");
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,755 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { RustExtractor } from "../rust-extractor.js";
const require = createRequire(import.meta.url);
// Load tree-sitter + Rust grammar once
let Parser: any;
let Language: any;
let rustLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"tree-sitter-rust/tree-sitter-rust.wasm",
);
rustLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(rustLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("RustExtractor", () => {
const extractor = new RustExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["rust"]);
});
// ---- Functions ----
describe("extractStructure - functions", () => {
it("extracts top-level functions with params and return types", () => {
const { tree, parser, root } = parse(`
pub fn check_port(port: u16) -> bool {
port > 0
}
fn helper(name: String, count: i32) -> String {
name.repeat(count)
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(2);
expect(result.functions[0].name).toBe("check_port");
expect(result.functions[0].params).toEqual(["port"]);
expect(result.functions[0].returnType).toBe("bool");
expect(result.functions[1].name).toBe("helper");
expect(result.functions[1].params).toEqual(["name", "count"]);
expect(result.functions[1].returnType).toBe("String");
tree.delete();
parser.delete();
});
it("extracts functions with no params and no return type", () => {
const { tree, parser, root } = parse(`
fn noop() {
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("reports correct line ranges for multi-line functions", () => {
const { tree, parser, root } = parse(`
fn multiline(
a: i32,
b: i32,
) -> i32 {
let result = a + b;
result
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].lineRange[0]).toBe(2);
expect(result.functions[0].lineRange[1]).toBe(8);
tree.delete();
parser.delete();
});
});
// ---- Methods (impl blocks) ----
describe("extractStructure - impl blocks and methods", () => {
it("extracts methods from impl blocks and links them to structs", () => {
const { tree, parser, root } = parse(`
pub struct Config {
name: String,
port: u16,
}
impl Config {
pub fn new(name: String, port: u16) -> Self {
Config { name, port }
}
fn validate(&self) -> bool {
true
}
}
`);
const result = extractor.extractStructure(root);
// Methods appear in functions list
const fnNames = result.functions.map((f) => f.name);
expect(fnNames).toContain("new");
expect(fnNames).toContain("validate");
// new() skips self parameter, captures real params
const newFn = result.functions.find((f) => f.name === "new");
expect(newFn?.params).toEqual(["name", "port"]);
expect(newFn?.returnType).toBe("Self");
// validate() has &self — should have no params
const validateFn = result.functions.find((f) => f.name === "validate");
expect(validateFn?.params).toEqual([]);
expect(validateFn?.returnType).toBe("bool");
// Methods linked to struct
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Config");
expect(result.classes[0].methods).toContain("new");
expect(result.classes[0].methods).toContain("validate");
tree.delete();
parser.delete();
});
it("handles impl blocks for enums", () => {
const { tree, parser, root } = parse(`
enum Status {
Active,
Inactive,
}
impl Status {
fn is_active(&self) -> bool {
true
}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Status");
expect(result.classes[0].methods).toContain("is_active");
tree.delete();
parser.delete();
});
});
// ---- Structs ----
describe("extractStructure - structs", () => {
it("extracts struct with fields", () => {
const { tree, parser, root } = parse(`
pub struct Config {
name: String,
port: u16,
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Config");
expect(result.classes[0].properties).toEqual(["name", "port"]);
expect(result.classes[0].methods).toEqual([]);
expect(result.classes[0].lineRange[0]).toBe(2);
tree.delete();
parser.delete();
});
it("extracts empty struct (unit struct)", () => {
const { tree, parser, root } = parse(`
struct Empty;
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Empty");
expect(result.classes[0].properties).toEqual([]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
});
// ---- Enums ----
describe("extractStructure - enums", () => {
it("extracts enum with variants as properties", () => {
const { tree, parser, root } = parse(`
enum Status {
Active,
Inactive,
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Status");
expect(result.classes[0].properties).toEqual(["Active", "Inactive"]);
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts pub enum", () => {
const { tree, parser, root } = parse(`
pub enum Direction {
North,
South,
East,
West,
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Direction");
expect(result.classes[0].properties).toEqual(["North", "South", "East", "West"]);
// pub enum should be exported
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Direction");
tree.delete();
parser.delete();
});
});
// ---- Traits ----
describe("extractStructure - traits", () => {
it("extracts trait with method signatures", () => {
const { tree, parser, root } = parse(`
pub trait Validator {
fn validate(&self) -> bool;
fn name(&self) -> &str;
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Validator");
expect(result.classes[0].methods).toEqual(["validate", "name"]);
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts empty trait", () => {
const { tree, parser, root } = parse(`
trait Marker {}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Marker");
expect(result.classes[0].methods).toEqual([]);
tree.delete();
parser.delete();
});
it("exports pub traits", () => {
const { tree, parser, root } = parse(`
pub trait Serializable {
fn serialize(&self) -> String;
}
trait Internal {
fn process(&self);
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Serializable");
expect(exportNames).not.toContain("Internal");
tree.delete();
parser.delete();
});
});
// ---- Imports ----
describe("extractStructure - imports", () => {
it("extracts scoped identifier imports", () => {
const { tree, parser, root } = parse(`
use std::collections::HashMap;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("std::collections");
expect(result.imports[0].specifiers).toEqual(["HashMap"]);
tree.delete();
parser.delete();
});
it("extracts scoped use list imports", () => {
const { tree, parser, root } = parse(`
use std::io::{self, Read, Write};
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("std::io");
expect(result.imports[0].specifiers).toEqual(["self", "Read", "Write"]);
tree.delete();
parser.delete();
});
it("extracts wildcard imports", () => {
const { tree, parser, root } = parse(`
use std::prelude::*;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("std::prelude");
expect(result.imports[0].specifiers).toEqual(["*"]);
tree.delete();
parser.delete();
});
it("extracts simple identifier imports", () => {
const { tree, parser, root } = parse(`
use foo;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("foo");
expect(result.imports[0].specifiers).toEqual(["foo"]);
tree.delete();
parser.delete();
});
it("extracts crate-relative imports", () => {
const { tree, parser, root } = parse(`
use crate::config::Settings;
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("crate::config");
expect(result.imports[0].specifiers).toEqual(["Settings"]);
tree.delete();
parser.delete();
});
it("reports correct import line numbers", () => {
const { tree, parser, root } = parse(`
use std::collections::HashMap;
use std::io::{self, Read};
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(2);
expect(result.imports[0].lineNumber).toBe(2);
expect(result.imports[1].lineNumber).toBe(3);
tree.delete();
parser.delete();
});
});
// ---- Exports ----
describe("extractStructure - exports", () => {
it("exports pub items and not private items", () => {
const { tree, parser, root } = parse(`
pub struct Config {
name: String,
}
struct Internal {
value: i32,
}
pub fn check_port(port: u16) -> bool {
port > 0
}
fn helper() {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Config");
expect(exportNames).toContain("check_port");
expect(exportNames).not.toContain("Internal");
expect(exportNames).not.toContain("helper");
tree.delete();
parser.delete();
});
it("exports pub methods inside impl blocks", () => {
const { tree, parser, root } = parse(`
pub struct Config {
name: String,
}
impl Config {
pub fn new(name: String) -> Self {
Config { name }
}
fn validate(&self) -> bool {
true
}
}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toContain("Config");
expect(exportNames).toContain("new");
expect(exportNames).not.toContain("validate");
tree.delete();
parser.delete();
});
it("reports correct export line numbers", () => {
const { tree, parser, root } = parse(`
pub struct Config {
name: String,
}
pub fn check_port(port: u16) -> bool {
port > 0
}
`);
const result = extractor.extractStructure(root);
const configExport = result.exports.find((e) => e.name === "Config");
expect(configExport?.lineNumber).toBe(2);
const checkPortExport = result.exports.find((e) => e.name === "check_port");
expect(checkPortExport?.lineNumber).toBe(6);
tree.delete();
parser.delete();
});
});
// ---- Call Graph ----
describe("extractCallGraph", () => {
it("extracts simple function calls", () => {
const { tree, parser, root } = parse(`
fn validate(port: u16) -> bool {
check_port(port)
}
fn main() {
validate(8080);
}
`);
const result = extractor.extractCallGraph(root);
const validateCalls = result.filter((e) => e.caller === "validate");
expect(validateCalls.some((e) => e.callee === "check_port")).toBe(true);
const mainCalls = result.filter((e) => e.caller === "main");
expect(mainCalls.some((e) => e.callee === "validate")).toBe(true);
tree.delete();
parser.delete();
});
it("extracts method calls (field_expression)", () => {
const { tree, parser, root } = parse(`
fn process(&self) {
self.validate();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("process");
expect(result[0].callee).toBe("self.validate");
tree.delete();
parser.delete();
});
it("extracts scoped calls (e.g., Vec::new)", () => {
const { tree, parser, root } = parse(`
fn create() {
Vec::new();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("create");
expect(result[0].callee).toBe("Vec::new");
tree.delete();
parser.delete();
});
it("tracks correct caller for methods in impl blocks", () => {
const { tree, parser, root } = parse(`
impl Config {
fn validate(&self) -> bool {
check_port(self.port)
}
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(1);
expect(result[0].caller).toBe("validate");
expect(result[0].callee).toBe("check_port");
tree.delete();
parser.delete();
});
it("reports correct line numbers for calls", () => {
const { tree, parser, root } = parse(`
fn main() {
foo();
bar();
}
`);
const result = extractor.extractCallGraph(root);
expect(result).toHaveLength(2);
expect(result[0].lineNumber).toBe(3);
expect(result[1].lineNumber).toBe(4);
tree.delete();
parser.delete();
});
it("ignores top-level calls (no caller)", () => {
const { tree, parser, root } = parse(`
static X: i32 = compute();
`);
const result = extractor.extractCallGraph(root);
// Top-level calls have no enclosing function, so they are skipped
expect(result).toHaveLength(0);
tree.delete();
parser.delete();
});
});
// ---- Comprehensive ----
describe("comprehensive Rust file", () => {
it("handles the full test scenario from the spec", () => {
const { tree, parser, root } = parse(`use std::collections::HashMap;
use std::io::{self, Read};
pub struct Config {
name: String,
port: u16,
}
impl Config {
pub fn new(name: String, port: u16) -> Self {
Config { name, port }
}
fn validate(&self) -> bool {
check_port(self.port)
}
}
pub fn check_port(port: u16) -> bool {
port > 0
}
enum Status {
Active,
Inactive,
}
`);
const result = extractor.extractStructure(root);
// Functions: new, validate, check_port
expect(result.functions).toHaveLength(3);
const fnNames = result.functions.map((f) => f.name).sort();
expect(fnNames).toEqual(["check_port", "new", "validate"]);
// new() params
const newFn = result.functions.find((f) => f.name === "new");
expect(newFn?.params).toEqual(["name", "port"]);
expect(newFn?.returnType).toBe("Self");
// validate() has &self — no visible params
const validateFn = result.functions.find((f) => f.name === "validate");
expect(validateFn?.params).toEqual([]);
expect(validateFn?.returnType).toBe("bool");
// check_port()
const checkPortFn = result.functions.find((f) => f.name === "check_port");
expect(checkPortFn?.params).toEqual(["port"]);
expect(checkPortFn?.returnType).toBe("bool");
// Classes: Config (struct) and Status (enum)
expect(result.classes).toHaveLength(2);
const configClass = result.classes.find((c) => c.name === "Config");
expect(configClass).toBeDefined();
expect(configClass!.properties).toEqual(["name", "port"]);
expect(configClass!.methods).toContain("new");
expect(configClass!.methods).toContain("validate");
const statusClass = result.classes.find((c) => c.name === "Status");
expect(statusClass).toBeDefined();
expect(statusClass!.properties).toEqual(["Active", "Inactive"]);
expect(statusClass!.methods).toEqual([]);
// Imports: 2
expect(result.imports).toHaveLength(2);
expect(result.imports[0].source).toBe("std::collections");
expect(result.imports[0].specifiers).toEqual(["HashMap"]);
expect(result.imports[1].source).toBe("std::io");
expect(result.imports[1].specifiers).toEqual(["self", "Read"]);
// Exports: Config, new, check_port (those with pub)
const exportNames = result.exports.map((e) => e.name).sort();
expect(exportNames).toEqual(["Config", "check_port", "new"]);
// Call graph: validate -> check_port
const calls = extractor.extractCallGraph(root);
const validateCalls = calls.filter((e) => e.caller === "validate");
expect(validateCalls).toHaveLength(1);
expect(validateCalls[0].callee).toBe("check_port");
tree.delete();
parser.delete();
});
it("handles a realistic Rust module with traits and multiple impls", () => {
const { tree, parser, root } = parse(`use std::fmt;
use std::io::{self, Write};
pub trait Displayable {
fn display(&self) -> String;
}
pub struct Server {
host: String,
port: u16,
}
impl Server {
pub fn new(host: String, port: u16) -> Self {
Server { host, port }
}
pub fn start(&self) {
listen(self.port);
}
}
impl Displayable for Server {
fn display(&self) -> String {
format_server(self.host.clone(), self.port)
}
}
fn listen(port: u16) {
println!("Listening on port {}", port);
}
fn format_server(host: String, port: u16) -> String {
format!("{}:{}", host, port)
}
`);
const result = extractor.extractStructure(root);
// Functions: new, start, display, listen, format_server
expect(result.functions).toHaveLength(5);
// Trait: Displayable
const trait_ = result.classes.find((c) => c.name === "Displayable");
expect(trait_).toBeDefined();
expect(trait_!.methods).toEqual(["display"]);
// Struct: Server
const server = result.classes.find((c) => c.name === "Server");
expect(server).toBeDefined();
expect(server!.properties).toEqual(["host", "port"]);
// Methods from both impl blocks (Server and Displayable for Server)
expect(server!.methods).toContain("new");
expect(server!.methods).toContain("start");
expect(server!.methods).toContain("display");
// Exports: pub items
const exportNames = result.exports.map((e) => e.name).sort();
expect(exportNames).toContain("Displayable");
expect(exportNames).toContain("Server");
expect(exportNames).toContain("new");
expect(exportNames).toContain("start");
expect(exportNames).not.toContain("listen");
expect(exportNames).not.toContain("format_server");
// Call graph
const calls = extractor.extractCallGraph(root);
const startCalls = calls.filter((e) => e.caller === "start");
expect(startCalls.some((e) => e.callee === "listen")).toBe(true);
const displayCalls = calls.filter((e) => e.caller === "display");
expect(displayCalls.some((e) => e.callee === "format_server")).toBe(true);
tree.delete();
parser.delete();
});
});
});
@@ -0,0 +1,52 @@
import type { TreeSitterNode } from "./types.js";
/** Recursively traverse an AST tree, calling the visitor for each node. */
export function traverse(
node: TreeSitterNode,
visitor: (node: TreeSitterNode) => void,
): void {
visitor(node);
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) traverse(child, visitor);
}
}
/** Extract the unquoted string value from a string-like node. */
export function getStringValue(node: TreeSitterNode): string {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === "string_fragment") {
return child.text;
}
}
return node.text.replace(/^['"`]|['"`]$/g, "");
}
/** Find the first child matching a type. */
export function findChild(node: TreeSitterNode, type: string): TreeSitterNode | null {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === type) return child;
}
return null;
}
/** Find all children matching a type. */
export function findChildren(node: TreeSitterNode, type: string): TreeSitterNode[] {
const result: TreeSitterNode[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === type) result.push(child);
}
return result;
}
/** Check if a node has a child of the given type (used for export/visibility checks). */
export function hasChildOfType(node: TreeSitterNode, type: string): boolean {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === type) return true;
}
return false;
}
@@ -0,0 +1,501 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Recursively unwrap nested declarators (pointer_declarator, reference_declarator,
* array_declarator) to find the leaf identifier name.
*
* C/C++ parameter declarators can be deeply nested:
* `char** pp` => pointer_declarator -> pointer_declarator -> identifier("pp")
* `const std::string& ref` => reference_declarator -> identifier("ref")
* `int arr[]` => array_declarator -> identifier("arr")
*/
function unwrapDeclaratorName(node: TreeSitterNode): string | null {
if (node.type === "identifier" || node.type === "field_identifier") {
return node.text;
}
// Dig into the nested declarator field
const inner = node.childForFieldName("declarator");
if (inner) {
return unwrapDeclaratorName(inner);
}
// Fallback: look for direct identifier/field_identifier child
const id = findChild(node, "identifier") ?? findChild(node, "field_identifier");
return id ? id.text : null;
}
/**
* Extract the function/method name from a function_declarator node.
*
* The declarator field can be:
* - `identifier` for free functions: `int baz(int y)`
* - `field_identifier` for in-class declarations/definitions: `void start();`
* - `qualified_identifier` for out-of-class definitions: `void Server::start()`
*
* For qualified_identifier, we extract just the final name (e.g., "start"),
* but also return the qualifier (e.g., "Server") to associate methods with classes.
*/
function extractFuncDeclName(
funcDecl: TreeSitterNode,
): { name: string; qualifier: string | null } | null {
const declNode = funcDecl.childForFieldName("declarator");
if (!declNode) return null;
if (declNode.type === "identifier" || declNode.type === "field_identifier") {
return { name: declNode.text, qualifier: null };
}
if (declNode.type === "qualified_identifier") {
const nameNode = declNode.childForFieldName("name");
// The qualifier is the namespace_identifier before ::
const nsNode = findChild(declNode, "namespace_identifier");
return {
name: nameNode ? nameNode.text : declNode.text,
qualifier: nsNode ? nsNode.text : null,
};
}
return { name: declNode.text, qualifier: null };
}
/**
* Extract parameter names from a parameter_list node.
*
* Each parameter_declaration has a `declarator` field which may be an identifier,
* pointer_declarator, reference_declarator, or array_declarator. We recursively
* unwrap to find the actual name.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
const decls = findChildren(paramsNode, "parameter_declaration");
for (const decl of decls) {
const declNode = decl.childForFieldName("declarator");
if (declNode) {
const name = unwrapDeclaratorName(declNode);
if (name) {
params.push(name);
}
}
}
return params;
}
/**
* Extract the return type text from a function_definition node.
*
* The return type is the `type` named field on function_definition.
* Can be primitive_type, qualified_identifier, type_identifier, etc.
*/
function extractReturnType(node: TreeSitterNode): string | undefined {
const typeNode = node.childForFieldName("type");
if (typeNode) {
return typeNode.text;
}
return undefined;
}
/**
* Check if a function_definition has a `storage_class_specifier` child with "static".
*/
function isStatic(node: TreeSitterNode): boolean {
const storage = findChild(node, "storage_class_specifier");
return storage !== null && storage.text === "static";
}
/**
* C/C++ extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles:
* - Free functions (function_definition)
* - Classes (class_specifier) with methods, properties, and access specifiers
* - Structs (struct_specifier) with fields
* - #include directives mapped to imports
* - Namespaces (namespace_definition) with recursive traversal
* - Out-of-class method definitions (e.g., void Server::start())
* - Call graph extraction from call_expression nodes
*
* C/C++ has no formal export syntax. Non-static top-level functions and
* public class/struct members are treated as exports.
*/
export class CppExtractor implements LanguageExtractor {
readonly languageIds = ["cpp"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
// Track methods associated with classes via out-of-class definitions
const methodsByClass = new Map<string, string[]>();
this.walkTopLevel(rootNode, functions, classes, imports, exports, methodsByClass);
// Attach out-of-class methods to their corresponding classes
for (const cls of classes) {
const methods = methodsByClass.get(cls.name);
if (methods) {
for (const m of methods) {
if (!cls.methods.includes(m)) {
cls.methods.push(m);
}
}
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering function_definition
if (node.type === "function_definition") {
const name = this.extractFunctionName(node);
if (name) {
functionStack.push(name);
pushedName = true;
}
}
// Extract call_expression nodes
if (node.type === "call_expression") {
if (functionStack.length > 0) {
const callee = this.extractCalleeName(node);
if (callee) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee,
lineNumber: node.startPosition.row + 1,
});
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
/**
* Walk top-level declarations. Recurses into namespace_definition bodies
* to find nested declarations.
*/
private walkTopLevel(
parentNode: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
imports: StructuralAnalysis["imports"],
exports: StructuralAnalysis["exports"],
methodsByClass: Map<string, string[]>,
): void {
for (let i = 0; i < parentNode.childCount; i++) {
const node = parentNode.child(i);
if (!node) continue;
switch (node.type) {
case "preproc_include":
this.extractInclude(node, imports);
break;
case "class_specifier":
this.extractClassOrStruct(node, "class", classes, functions, exports);
break;
case "struct_specifier":
this.extractClassOrStruct(node, "struct", classes, functions, exports);
break;
case "function_definition":
this.extractFunctionDef(node, functions, exports, methodsByClass);
break;
case "namespace_definition": {
// Recurse into namespace body (declaration_list)
const body = findChild(node, "declaration_list");
if (body) {
this.walkTopLevel(body, functions, classes, imports, exports, methodsByClass);
}
break;
}
case "declaration": {
// A top-level ";" terminated statement — could be a class/struct with a trailing ;
// e.g., `class Foo { ... };` parses the class_specifier as a child of a
// declaration in some contexts. Check for nested class/struct specifiers.
const innerClass = findChild(node, "class_specifier");
if (innerClass) {
this.extractClassOrStruct(innerClass, "class", classes, functions, exports);
}
const innerStruct = findChild(node, "struct_specifier");
if (innerStruct) {
this.extractClassOrStruct(innerStruct, "struct", classes, functions, exports);
}
break;
}
}
}
}
/**
* Extract the simple function name from a function_definition.
* For qualified names (e.g., Server::start), returns just the method name.
*/
private extractFunctionName(node: TreeSitterNode): string | null {
const declNode = node.childForFieldName("declarator");
if (!declNode || declNode.type !== "function_declarator") return null;
const info = extractFuncDeclName(declNode);
return info ? info.name : null;
}
/**
* Extract #include directives and map them to the imports array.
*
* `preproc_include` has a `path` field that is either:
* - `system_lib_string` for angle-bracket includes: `<iostream>`
* - `string_literal` for quoted includes: `"myfile.h"`
*/
private extractInclude(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const pathNode = node.childForFieldName("path");
if (!pathNode) return;
let source: string;
if (pathNode.type === "system_lib_string") {
// Strip angle brackets: <iostream> -> iostream
source = pathNode.text.replace(/^<|>$/g, "");
} else if (pathNode.type === "string_literal") {
// Extract content from string: "myfile.h" -> myfile.h
const content = findChild(pathNode, "string_content");
source = content ? content.text : pathNode.text.replace(/^"|"$/g, "");
} else {
source = pathNode.text;
}
imports.push({
source,
specifiers: [source],
lineNumber: node.startPosition.row + 1,
});
}
/**
* Extract class_specifier or struct_specifier into the classes array.
*
* Processes:
* - Properties (field_declaration without function_declarator)
* - Method declarations (field_declaration with function_declarator)
* - Method definitions (function_definition inside the class body)
* - Access specifiers (public/private/protected)
*
* Public members of classes and all members of structs (default public)
* are treated as exports.
*/
private extractClassOrStruct(
node: TreeSitterNode,
kind: "class" | "struct",
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const className = nameNode.text;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body && body.type === "field_declaration_list") {
// Default access: public for struct, private for class
let currentAccess = kind === "struct" ? "public" : "private";
for (let j = 0; j < body.childCount; j++) {
const member = body.child(j);
if (!member) continue;
if (member.type === "access_specifier") {
// Update current access level
const specChild = member.child(0);
if (specChild) {
currentAccess = specChild.text;
}
continue;
}
if (member.type === "field_declaration") {
const declNode = member.childForFieldName("declarator");
if (declNode && declNode.type === "function_declarator") {
// Method declaration (no body)
const info = extractFuncDeclName(declNode);
if (info) {
methods.push(info.name);
if (currentAccess === "public") {
exports.push({
name: info.name,
lineNumber: member.startPosition.row + 1,
});
}
}
} else if (declNode) {
// Property (field_identifier or other declarator)
const name = unwrapDeclaratorName(declNode);
if (name) {
properties.push(name);
}
}
}
if (member.type === "function_definition") {
// Inline method definition
const funcDecl = member.childForFieldName("declarator");
if (funcDecl && funcDecl.type === "function_declarator") {
const info = extractFuncDeclName(funcDecl);
if (info) {
methods.push(info.name);
// Also add to functions list with params/return type
const paramsNode = funcDecl.childForFieldName("parameters");
functions.push({
name: info.name,
lineRange: [
member.startPosition.row + 1,
member.endPosition.row + 1,
],
params: extractParams(paramsNode),
returnType: extractReturnType(member),
});
if (currentAccess === "public") {
exports.push({
name: info.name,
lineNumber: member.startPosition.row + 1,
});
}
}
}
}
}
}
classes.push({
name: className,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
// The class/struct name itself is an export (non-anonymous types are always exported in C/C++ headers)
exports.push({
name: className,
lineNumber: node.startPosition.row + 1,
});
}
/**
* Extract a free function or out-of-class method definition.
*
* For qualified names (e.g., `void Server::start()`), the method is:
* - Added to the functions array
* - Tracked in methodsByClass for later association with the class
* - Exported if non-static
*
* Static functions are NOT exported.
*/
private extractFunctionDef(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
methodsByClass: Map<string, string[]>,
): void {
const funcDecl = node.childForFieldName("declarator");
if (!funcDecl || funcDecl.type !== "function_declarator") return;
const info = extractFuncDeclName(funcDecl);
if (!info) return;
const paramsNode = funcDecl.childForFieldName("parameters");
const params = extractParams(paramsNode);
const returnType = extractReturnType(node);
functions.push({
name: info.name,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
// Track out-of-class method definitions (e.g., void Server::start())
if (info.qualifier) {
if (!methodsByClass.has(info.qualifier)) {
methodsByClass.set(info.qualifier, []);
}
methodsByClass.get(info.qualifier)!.push(info.name);
}
// Non-static top-level functions are exports
if (!isStatic(node)) {
exports.push({
name: info.name,
lineNumber: node.startPosition.row + 1,
});
}
}
/**
* Extract the callee name from a call_expression.
*
* Handles:
* - Plain function call: `printf(...)` -> "printf"
* - Member call via field_expression: `p->method()` -> "p->method"
* - Scoped call: `std::cout << ...` -> qualified name text
*/
private extractCalleeName(callNode: TreeSitterNode): string | null {
const funcNode = callNode.child(0);
if (!funcNode) return null;
if (funcNode.type === "identifier") {
return funcNode.text;
}
if (funcNode.type === "field_expression") {
const field = funcNode.childForFieldName("field");
return field ? field.text : funcNode.text;
}
if (funcNode.type === "qualified_identifier") {
return funcNode.text;
}
return funcNode.text;
}
}
@@ -0,0 +1,524 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Extract parameter names from a C# `parameter_list` node.
*
* Each `parameter` child has a `name` field (identifier) and a `type` field.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
const paramNodes = findChildren(paramsNode, "parameter");
for (const param of paramNodes) {
const nameNode = param.childForFieldName("name");
if (nameNode) {
params.push(nameNode.text);
}
}
return params;
}
/**
* Extract the return type text from a method_declaration node.
*
* In tree-sitter-c-sharp, the return type is the `returns` named field.
* It can be a predefined_type (void, int, string), generic_name (List<T>),
* identifier, nullable_type, etc.
*/
function extractReturnType(node: TreeSitterNode): string | undefined {
const typeNode = node.childForFieldName("returns");
if (!typeNode) return undefined;
return typeNode.text;
}
/**
* Check if a C# declaration node has a specific modifier.
*
* Unlike Java (which has a single `modifiers` container), C# tree-sitter
* emits multiple separate `modifier` nodes as direct children of the
* declaration. Each modifier node contains a single keyword child
* (e.g., `public`, `private`, `static`).
*/
function hasModifier(node: TreeSitterNode, modifier: string): boolean {
const modifierNodes = findChildren(node, "modifier");
for (const mod of modifierNodes) {
for (let i = 0; i < mod.childCount; i++) {
const child = mod.child(i);
if (child && child.text === modifier) return true;
}
}
return false;
}
/**
* Extract the namespace source text from a using_directive.
*
* Handles both simple identifiers (`using System;`) and qualified names
* (`using System.Collections.Generic;`). For aliased usings like
* `using Alias = Some.Namespace;`, extracts the target namespace.
*/
function extractUsingSource(node: TreeSitterNode): string | null {
// Check for alias form: `using Alias = Some.Namespace;`
const hasEquals = findChild(node, "=") !== null;
if (hasEquals) {
// The target namespace is the qualified_name after the `=`
const qualifiedName = findChild(node, "qualified_name");
return qualifiedName ? qualifiedName.text : null;
}
// Simple or qualified using
const qualifiedName = findChild(node, "qualified_name");
if (qualifiedName) return qualifiedName.text;
const identifier = findChild(node, "identifier");
return identifier ? identifier.text : null;
}
/**
* Get the last component of a dotted namespace path.
* e.g. "System.Collections.Generic" -> "Generic"
*/
function lastComponent(path: string): string {
const parts = path.split(".");
return parts[parts.length - 1];
}
/**
* Extract the callee name from an invocation_expression node.
*
* Handles:
* - Plain method call: `FetchFromDb(limit)` -> "FetchFromDb"
* (function field is an identifier)
* - Qualified call: `Console.WriteLine(msg)` -> "Console.WriteLine"
* (function field is a member_access_expression)
*/
function extractInvocationName(node: TreeSitterNode): string | null {
const funcNode = node.childForFieldName("function");
if (!funcNode) return null;
return funcNode.text;
}
/**
* C# extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles classes, interfaces, methods, constructors, properties, fields,
* using directives, visibility-based exports, and call graphs for C# source code.
*
* C#-specific mapping decisions:
* - Classes and interfaces are mapped to the `classes` array.
* - Constructors are mapped to the `functions` array (named after the class).
* - Methods (including interface method signatures) are listed in the
* containing class/interface's `methods` array and also in the `functions` array.
* - Properties (e.g., `public string Name { get; set; }`) are extracted into
* the containing class's `properties` array alongside fields.
* - Exports are determined by the `public` modifier on classes, interfaces,
* methods, constructors, properties, and fields.
* - Namespaces: both block-scoped (`namespace Foo { ... }`) and file-scoped
* (`namespace Foo;`) are traversed to find declarations.
* - Using directives are mapped to imports, with the last dotted component
* as the specifier.
*/
export class CSharpExtractor implements LanguageExtractor {
readonly languageIds = ["csharp"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
this.walkTopLevel(rootNode, functions, classes, imports, exports);
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering method/constructor declarations
if (
node.type === "method_declaration" ||
node.type === "constructor_declaration"
) {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
}
// Extract method invocations: e.g. FetchFromDb(limit), Console.WriteLine(msg)
if (node.type === "invocation_expression") {
if (functionStack.length > 0) {
const callee = extractInvocationName(node);
if (callee) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee,
lineNumber: node.startPosition.row + 1,
});
}
}
}
// Extract object creation: e.g. new Foo()
if (node.type === "object_creation_expression") {
if (functionStack.length > 0) {
// The type is the child after `new` — can be identifier or generic_name
const typeNode = findChild(node, "identifier") ?? findChild(node, "generic_name");
if (typeNode) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: `new ${typeNode.text}`,
lineNumber: node.startPosition.row + 1,
});
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
/**
* Walk the top-level nodes of a compilation_unit, recursing into
* namespace bodies to find declarations.
*/
private walkTopLevel(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
imports: StructuralAnalysis["imports"],
exports: StructuralAnalysis["exports"],
): void {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
switch (child.type) {
case "using_directive":
this.extractUsing(child, imports);
break;
case "namespace_declaration":
// Recurse into namespace body (declaration_list)
this.walkNamespaceBody(child, functions, classes, imports, exports);
break;
case "file_scoped_namespace_declaration":
// File-scoped namespace: declarations are siblings at the root,
// not children of this node. Nothing to recurse into.
break;
case "class_declaration":
this.extractClass(child, functions, classes, exports);
break;
case "interface_declaration":
this.extractInterface(child, functions, classes, exports);
break;
}
}
}
/**
* Walk into a namespace_declaration's body (declaration_list) to find
* classes, interfaces, and nested namespaces.
*/
private walkNamespaceBody(
nsNode: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
imports: StructuralAnalysis["imports"],
exports: StructuralAnalysis["exports"],
): void {
const body = nsNode.childForFieldName("body");
if (!body) return;
for (let i = 0; i < body.childCount; i++) {
const child = body.child(i);
if (!child) continue;
switch (child.type) {
case "class_declaration":
this.extractClass(child, functions, classes, exports);
break;
case "interface_declaration":
this.extractInterface(child, functions, classes, exports);
break;
case "namespace_declaration":
// Nested namespaces
this.walkNamespaceBody(child, functions, classes, imports, exports);
break;
}
}
}
private extractUsing(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const source = extractUsingSource(node);
if (!source) return;
imports.push({
source,
specifiers: [lastComponent(source)],
lineNumber: node.startPosition.row + 1,
});
}
private extractClass(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
this.extractClassBodyMembers(body, methods, properties, functions, exports);
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractInterface(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
// Interface body contains method_declaration nodes (signatures without bodies)
const methodNodes = findChildren(body, "method_declaration");
for (const methodNode of methodNodes) {
const methNameNode = methodNode.childForFieldName("name");
if (methNameNode) {
methods.push(methNameNode.text);
}
}
// Interface can contain property_declaration nodes
const propNodes = findChildren(body, "property_declaration");
for (const propNode of propNodes) {
const propNameNode = propNode.childForFieldName("name");
if (propNameNode) {
properties.push(propNameNode.text);
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
/**
* Extract methods, constructors, properties, and fields from a
* class declaration_list body.
*/
private extractClassBodyMembers(
body: TreeSitterNode,
methods: string[],
properties: string[],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
for (let i = 0; i < body.childCount; i++) {
const child = body.child(i);
if (!child) continue;
switch (child.type) {
case "method_declaration":
this.extractMethod(child, methods, functions, exports);
break;
case "constructor_declaration":
this.extractConstructor(child, methods, functions, exports);
break;
case "property_declaration":
this.extractProperty(child, properties, exports);
break;
case "field_declaration":
this.extractField(child, properties, exports);
break;
}
}
}
private extractMethod(
node: TreeSitterNode,
methods: string[],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
const returnType = extractReturnType(node);
methods.push(nameNode.text);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractConstructor(
node: TreeSitterNode,
methods: string[],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
methods.push(nameNode.text);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
// Constructors have no return type
});
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractProperty(
node: TreeSitterNode,
properties: string[],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
properties.push(nameNode.text);
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractField(
node: TreeSitterNode,
properties: string[],
exports: StructuralAnalysis["exports"],
): void {
// field_declaration -> variable_declaration -> variable_declarator(s)
const varDecl = findChild(node, "variable_declaration");
if (!varDecl) return;
const declarators = findChildren(varDecl, "variable_declarator");
for (const decl of declarators) {
// variable_declarator's first child is the identifier
const nameNode = findChild(decl, "identifier");
if (nameNode) {
properties.push(nameNode.text);
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
}
}
}
@@ -0,0 +1,402 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Extract parameter names from a Go `parameter_list` node.
*
* Each child `parameter_declaration` has one or more `identifier` name children
* followed by a type node. Go allows unnamed params (e.g. in interface method
* signatures), which we skip since they have no user-visible name.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
const declarations = findChildren(paramsNode, "parameter_declaration");
for (const decl of declarations) {
// A parameter_declaration can have multiple name identifiers sharing
// a type, e.g. `a, b int`. Collect all identifiers.
for (let i = 0; i < decl.childCount; i++) {
const child = decl.child(i);
if (child && child.type === "identifier") {
params.push(child.text);
}
}
}
return params;
}
/**
* Extract the return type text from a function/method declaration's `result` field.
*
* Go supports three forms:
* - single type: `error` -> "error"
* - pointer type: `*Server` -> "*Server"
* - multiple returns via parameter_list: `(string, error)` -> "(string, error)"
*/
function extractResultType(node: TreeSitterNode): string | undefined {
const result = node.childForFieldName("result");
if (!result) return undefined;
return result.text;
}
/**
* Extract the receiver type name from a method_declaration's receiver parameter_list.
* Returns the base type name (without pointer star), e.g. `(s *Server)` -> "Server".
*/
function extractReceiverType(receiverNode: TreeSitterNode): string | undefined {
const decl = findChild(receiverNode, "parameter_declaration");
if (!decl) return undefined;
// Look for type_identifier directly or inside pointer_type
for (let i = 0; i < decl.childCount; i++) {
const child = decl.child(i);
if (!child) continue;
if (child.type === "type_identifier") {
return child.text;
}
if (child.type === "pointer_type") {
const typeId = findChild(child, "type_identifier");
if (typeId) return typeId.text;
}
}
return undefined;
}
/**
* Check if a name is exported in Go (starts with an uppercase letter).
*/
function isExported(name: string): boolean {
if (name.length === 0) return false;
const first = name.charCodeAt(0);
return first >= 65 && first <= 90; // A-Z
}
/**
* Go extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles functions, methods, structs, interfaces, imports, exports, and
* call graphs for Go source code.
*
* Go-specific mapping decisions:
* - Structs and interfaces are mapped to the `classes` array.
* - Methods (with receivers) are stored as functions and also listed
* in the corresponding struct's `methods` array.
* - Exports are determined by Go's capitalization convention.
*/
export class GoExtractor implements LanguageExtractor {
readonly languageIds = ["go"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
// Track methods per receiver type so we can attach them to structs
const methodsByReceiver = new Map<string, string[]>();
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
switch (node.type) {
case "function_declaration":
this.extractFunction(node, functions, exports);
break;
case "method_declaration":
this.extractMethod(node, functions, exports, methodsByReceiver);
break;
case "type_declaration":
this.extractTypeDeclaration(node, classes, exports);
break;
case "import_declaration":
this.extractImportDeclaration(node, imports);
break;
}
}
// Attach collected methods to their receiver structs/interfaces
for (const cls of classes) {
const methods = methodsByReceiver.get(cls.name);
if (methods) {
cls.methods.push(...methods);
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering function/method declarations
if (node.type === "function_declaration") {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
} else if (node.type === "method_declaration") {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
}
// Extract call expressions
if (node.type === "call_expression") {
const calleeNode = node.childForFieldName("function");
if (calleeNode && functionStack.length > 0) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: calleeNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
private extractFunction(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
const returnType = extractResultType(node);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
if (isExported(nameNode.text)) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractMethod(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
methodsByReceiver: Map<string, string[]>,
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
const returnType = extractResultType(node);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
// Track receiver type for struct association
const receiverNode = node.childForFieldName("receiver");
if (receiverNode) {
const receiverType = extractReceiverType(receiverNode);
if (receiverType) {
if (!methodsByReceiver.has(receiverType)) {
methodsByReceiver.set(receiverType, []);
}
methodsByReceiver.get(receiverType)!.push(nameNode.text);
}
}
if (isExported(nameNode.text)) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractTypeDeclaration(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const typeSpec = findChild(node, "type_spec");
if (!typeSpec) return;
const nameNode = typeSpec.childForFieldName("name");
const typeNode = typeSpec.childForFieldName("type");
if (!nameNode || !typeNode) return;
if (typeNode.type === "struct_type") {
this.extractStruct(node, nameNode, typeNode, classes, exports);
} else if (typeNode.type === "interface_type") {
this.extractInterface(node, nameNode, typeNode, classes, exports);
}
}
private extractStruct(
declNode: TreeSitterNode,
nameNode: TreeSitterNode,
structNode: TreeSitterNode,
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const properties: string[] = [];
const fieldList = findChild(structNode, "field_declaration_list");
if (fieldList) {
const fields = findChildren(fieldList, "field_declaration");
for (const field of fields) {
// A field_declaration can have multiple names: `X, Y int`
for (let i = 0; i < field.childCount; i++) {
const child = field.child(i);
if (child && child.type === "field_identifier") {
properties.push(child.text);
}
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
declNode.startPosition.row + 1,
declNode.endPosition.row + 1,
],
methods: [], // Methods are attached later from methodsByReceiver
properties,
});
if (isExported(nameNode.text)) {
exports.push({
name: nameNode.text,
lineNumber: declNode.startPosition.row + 1,
});
}
}
private extractInterface(
declNode: TreeSitterNode,
nameNode: TreeSitterNode,
interfaceNode: TreeSitterNode,
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const methods: string[] = [];
const methodElems = findChildren(interfaceNode, "method_elem");
for (const elem of methodElems) {
const methName = elem.childForFieldName("name");
if (methName) {
methods.push(methName.text);
}
}
classes.push({
name: nameNode.text,
lineRange: [
declNode.startPosition.row + 1,
declNode.endPosition.row + 1,
],
methods,
properties: [], // Interfaces have no properties
});
if (isExported(nameNode.text)) {
exports.push({
name: nameNode.text,
lineNumber: declNode.startPosition.row + 1,
});
}
}
private extractImportDeclaration(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
// Grouped imports: import ( ... )
const specList = findChild(node, "import_spec_list");
if (specList) {
const specs = findChildren(specList, "import_spec");
for (const spec of specs) {
this.extractImportSpec(spec, imports);
}
} else {
// Single import: import "fmt"
const spec = findChild(node, "import_spec");
if (spec) {
this.extractImportSpec(spec, imports);
}
}
}
private extractImportSpec(
spec: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const pathNode = spec.childForFieldName("path");
if (!pathNode) return;
// Extract unquoted path
const pathContent = findChild(pathNode, "interpreted_string_literal_content");
const source = pathContent ? pathContent.text : pathNode.text.replace(/^"|"$/g, "");
// Determine the specifier: alias if present, otherwise last path component
const nameNode = spec.childForFieldName("name");
let specifier: string;
if (nameNode) {
specifier = nameNode.text;
} else {
// Use last path component, e.g. "net/http" -> "http"
const parts = source.split("/");
specifier = parts[parts.length - 1];
}
imports.push({
source,
specifiers: [specifier],
lineNumber: spec.startPosition.row + 1,
});
}
}
@@ -0,0 +1,34 @@
export type { LanguageExtractor, TreeSitterNode } from "./types.js";
export { traverse, getStringValue, findChild, findChildren, hasChildOfType } from "./base-extractor.js";
export { TypeScriptExtractor } from "./typescript-extractor.js";
export { PythonExtractor } from "./python-extractor.js";
export { GoExtractor } from "./go-extractor.js";
export { RustExtractor } from "./rust-extractor.js";
export { JavaExtractor } from "./java-extractor.js";
export { RubyExtractor } from "./ruby-extractor.js";
export { PhpExtractor } from "./php-extractor.js";
export { CppExtractor } from "./cpp-extractor.js";
export { CSharpExtractor } from "./csharp-extractor.js";
import type { LanguageExtractor } from "./types.js";
import { TypeScriptExtractor } from "./typescript-extractor.js";
import { PythonExtractor } from "./python-extractor.js";
import { GoExtractor } from "./go-extractor.js";
import { RustExtractor } from "./rust-extractor.js";
import { JavaExtractor } from "./java-extractor.js";
import { RubyExtractor } from "./ruby-extractor.js";
import { PhpExtractor } from "./php-extractor.js";
import { CppExtractor } from "./cpp-extractor.js";
import { CSharpExtractor } from "./csharp-extractor.js";
export const builtinExtractors: LanguageExtractor[] = [
new TypeScriptExtractor(),
new PythonExtractor(),
new GoExtractor(),
new RustExtractor(),
new JavaExtractor(),
new RubyExtractor(),
new PhpExtractor(),
new CppExtractor(),
new CSharpExtractor(),
];
@@ -0,0 +1,449 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Extract parameter names from a Java `formal_parameters` node.
*
* Each `formal_parameter` child has a `name` field (identifier) and a `type` field.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
const declarations = findChildren(paramsNode, "formal_parameter");
for (const decl of declarations) {
const nameNode = decl.childForFieldName("name");
if (nameNode) {
params.push(nameNode.text);
}
}
// Also handle spread_parameter (varargs): e.g. `String... args`
const spreadParams = findChildren(paramsNode, "spread_parameter");
for (const spread of spreadParams) {
const nameNode = spread.childForFieldName("name");
if (nameNode) {
params.push(nameNode.text);
}
}
return params;
}
/**
* Extract the return type text from a method_declaration node.
*
* In tree-sitter-java, the return type is the `type` named field on method_declaration.
* It can be a type_identifier, generic_type, void_type, integral_type, etc.
*/
function extractReturnType(node: TreeSitterNode): string | undefined {
const typeNode = node.childForFieldName("type");
if (!typeNode) return undefined;
return typeNode.text;
}
/**
* Check if a node has a `modifiers` child containing a specific modifier keyword.
*/
function hasModifier(node: TreeSitterNode, modifier: string): boolean {
const modifiers = findChild(node, "modifiers");
if (!modifiers) return false;
for (let i = 0; i < modifiers.childCount; i++) {
const child = modifiers.child(i);
if (child && child.text === modifier) return true;
}
return false;
}
/**
* Extract the full dotted path from a scoped_identifier node.
*
* Java's scoped_identifier nests recursively:
* `java.util.List` is scoped_identifier(scope: scoped_identifier(scope: identifier "java",
* name: identifier "util"), name: identifier "List")
*
* This returns the full path as a dotted string.
*/
function extractScopedIdentifierPath(node: TreeSitterNode): string {
return node.text;
}
/**
* Get the last component of a dotted import path.
* e.g. "java.util.List" -> "List"
*/
function lastComponent(path: string): string {
const parts = path.split(".");
return parts[parts.length - 1];
}
/**
* Java extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles classes, interfaces, methods, constructors, fields, imports,
* visibility-based exports, and call graphs for Java source code.
*
* Java-specific mapping decisions:
* - Classes and interfaces are mapped to the `classes` array.
* - Constructors are mapped to the `functions` array (named after the class).
* - Methods (including interface method signatures) are listed in the
* containing class/interface's `methods` array and also in the `functions` array.
* - Exports are determined by the `public` modifier on classes, methods,
* constructors, and fields.
* - Fields are extracted as `properties` from `field_declaration` nodes.
*/
export class JavaExtractor implements LanguageExtractor {
readonly languageIds = ["java"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
switch (node.type) {
case "import_declaration":
this.extractImport(node, imports);
break;
case "class_declaration":
this.extractClass(node, functions, classes, exports);
break;
case "interface_declaration":
this.extractInterface(node, functions, classes, exports);
break;
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering method/constructor declarations
if (
node.type === "method_declaration" ||
node.type === "constructor_declaration"
) {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
}
// Extract method invocations: e.g. fetchFromDb(limit), System.out.println(msg)
if (node.type === "method_invocation") {
if (functionStack.length > 0) {
const callee = this.extractMethodInvocationName(node);
if (callee) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee,
lineNumber: node.startPosition.row + 1,
});
}
}
}
// Extract object creation: e.g. new Foo()
if (node.type === "object_creation_expression") {
if (functionStack.length > 0) {
const typeNode = node.childForFieldName("type");
if (typeNode) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: `new ${typeNode.text}`,
lineNumber: node.startPosition.row + 1,
});
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
/**
* Extract the callee name from a method_invocation node.
*
* Handles:
* - Plain method call: `fetchFromDb(limit)` -> "fetchFromDb"
* - Qualified call: `System.out.println(msg)` -> "System.out.println"
*/
private extractMethodInvocationName(node: TreeSitterNode): string | null {
const nameNode = node.childForFieldName("name");
if (!nameNode) return null;
const objectNode = node.childForFieldName("object");
if (objectNode) {
return `${objectNode.text}.${nameNode.text}`;
}
return nameNode.text;
}
private extractImport(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
// Check for asterisk (wildcard) import: `import java.util.*;`
const hasAsterisk = findChild(node, "asterisk") !== null;
const scopedId = findChild(node, "scoped_identifier");
if (!scopedId) return;
const fullPath = extractScopedIdentifierPath(scopedId);
if (hasAsterisk) {
// Wildcard import: source is the full scope, specifier is "*"
imports.push({
source: fullPath,
specifiers: ["*"],
lineNumber: node.startPosition.row + 1,
});
} else {
// Regular import: source is the full path, specifier is the last component
imports.push({
source: fullPath,
specifiers: [lastComponent(fullPath)],
lineNumber: node.startPosition.row + 1,
});
}
}
private extractClass(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
this.extractClassBodyMembers(
body,
methods,
properties,
functions,
exports,
);
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractInterface(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
// Interface body contains method_declaration nodes (signatures without bodies)
const methodNodes = findChildren(body, "method_declaration");
for (const methodNode of methodNodes) {
const methNameNode = methodNode.childForFieldName("name");
if (methNameNode) {
methods.push(methNameNode.text);
}
}
// Interface can also contain constant_declaration (fields)
const fields = findChildren(body, "constant_declaration");
for (const field of fields) {
const declarators = findChildren(field, "variable_declarator");
for (const decl of declarators) {
const declName = decl.childForFieldName("name");
if (declName) {
properties.push(declName.text);
}
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
/**
* Extract methods, constructors, and fields from a class_body node.
*/
private extractClassBodyMembers(
body: TreeSitterNode,
methods: string[],
properties: string[],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
for (let i = 0; i < body.childCount; i++) {
const child = body.child(i);
if (!child) continue;
switch (child.type) {
case "method_declaration":
this.extractMethod(child, methods, functions, exports);
break;
case "constructor_declaration":
this.extractConstructor(child, methods, functions, exports);
break;
case "field_declaration":
this.extractField(child, properties, exports);
break;
}
}
}
private extractMethod(
node: TreeSitterNode,
methods: string[],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
const returnType = extractReturnType(node);
methods.push(nameNode.text);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractConstructor(
node: TreeSitterNode,
methods: string[],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
methods.push(nameNode.text);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
// Constructors have no return type
});
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractField(
node: TreeSitterNode,
properties: string[],
exports: StructuralAnalysis["exports"],
): void {
const declarators = findChildren(node, "variable_declarator");
for (const decl of declarators) {
const nameNode = decl.childForFieldName("name");
if (nameNode) {
properties.push(nameNode.text);
if (hasModifier(node, "public")) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
}
}
}
@@ -0,0 +1,435 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Extract parameter names from a PHP `formal_parameters` node.
*
* Each child is a `simple_parameter` containing an optional type hint
* and a `variable_name` node (which itself has `$` + `name` children).
* We extract the variable name prefixed with `$`.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
const simpleParams = findChildren(paramsNode, "simple_parameter");
for (const param of simpleParams) {
const varName = findChild(param, "variable_name");
if (varName) {
params.push(varName.text);
}
}
return params;
}
/**
* Extract a return type string from the siblings following the `formal_parameters`
* in a function_definition or method_declaration node.
*
* In tree-sitter-php, the return type appears as a sibling after `:` and can be:
* - `primitive_type` (string, int, void, bool, etc.)
* - `named_type` (User, Repository, etc.)
* - `optional_type` (?string, ?User)
* - `union_type` (string|int)
*/
function extractReturnType(node: TreeSitterNode): string | undefined {
// Walk children looking for the colon separator that precedes the return type.
// The return type node follows the `:` and precedes the `compound_statement` (body).
let foundColon = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === ":" && child.text === ":") {
foundColon = true;
continue;
}
if (foundColon) {
// The next non-punctuation node after `:` is the return type
if (
child.type === "primitive_type" ||
child.type === "named_type" ||
child.type === "optional_type" ||
child.type === "union_type"
) {
return child.text;
}
}
}
return undefined;
}
/**
* Reconstruct a fully-qualified name from a `namespace_use_clause`.
*
* For a simple clause like `use App\Models\User;`, the clause contains
* a `qualified_name` with `namespace_name` segments and a trailing `name`.
* For a grouped clause like `use App\Models\{User, Post};`, the clause
* is a direct child of `namespace_use_group` and may be a bare `name`.
*/
function extractUseName(clause: TreeSitterNode, prefix: string): string {
const qualifiedName = findChild(clause, "qualified_name");
if (qualifiedName) {
return qualifiedName.text;
}
// Inside a grouped use, the clause may just be a `name` node
const nameNode = findChild(clause, "name");
if (nameNode && prefix) {
return prefix + "\\" + nameNode.text;
}
if (nameNode) {
return nameNode.text;
}
return clause.text;
}
/**
* Extract the last segment (class/interface name) from a fully-qualified name.
* e.g., "App\Models\User" -> "User"
*/
function lastSegment(fqn: string): string {
const parts = fqn.split("\\");
return parts[parts.length - 1];
}
/**
* PHP extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles functions, classes, interfaces, use imports, and call graphs
* for PHP source code parsed by tree-sitter-php.
*
* PHP-specific mapping decisions:
* - `function_definition` nodes map to the `functions` array.
* - `class_declaration` and `interface_declaration` map to the `classes` array.
* - `property_declaration` nodes within classes map to class properties.
* - `namespace_use_declaration` nodes (PHP `use` statements) map to imports.
* - PHP has no formal export syntax, so public classes, interfaces, and
* top-level functions are treated as exports.
* - Call graph covers `function_call_expression`, `member_call_expression`,
* and `scoped_call_expression`.
*/
export class PhpExtractor implements LanguageExtractor {
readonly languageIds = ["php"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
// tree-sitter-php wraps everything under `program`. The children of
// `program` include `php_tag`, `namespace_definition`, `namespace_use_declaration`,
// `class_declaration`, `function_definition`, etc.
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
switch (node.type) {
case "function_definition":
this.extractFunction(node, functions);
exports.push({
name: this.getFunctionName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "class_declaration":
this.extractClass(node, classes, functions);
exports.push({
name: this.getClassName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "interface_declaration":
this.extractInterface(node, classes);
exports.push({
name: this.getInterfaceName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "namespace_use_declaration":
this.extractUseDeclaration(node, imports);
break;
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering function/method definitions
if (node.type === "function_definition" || node.type === "method_declaration") {
const nameNode = findChild(node, "name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
}
// Extract call expressions
if (functionStack.length > 0) {
const caller = functionStack[functionStack.length - 1];
if (node.type === "function_call_expression") {
// Standalone function call: baz($x), strtoupper($x), error_log($msg)
const nameNode = findChild(node, "name");
if (nameNode) {
entries.push({
caller,
callee: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
} else if (node.type === "member_call_expression") {
// Instance method call: $this->fetchFromDb($id)
const nameNode = findChild(node, "name");
if (nameNode) {
// Determine the receiver for more descriptive callee
const firstChild = node.child(0);
const receiver = firstChild ? firstChild.text : "";
const callee = receiver
? receiver + "->" + nameNode.text
: nameNode.text;
entries.push({
caller,
callee,
lineNumber: node.startPosition.row + 1,
});
}
} else if (node.type === "scoped_call_expression") {
// Static method call: Bar::staticMethod()
// Children: [name("Bar"), ::, name("staticMethod"), arguments]
// Both scope and method are `name` nodes, so we pick child[0] for scope
// and child[2] (after `::`) for the method name.
const scopeNode = node.child(0);
const methodNode = node.child(2);
if (scopeNode && methodNode && methodNode.type === "name") {
entries.push({
caller,
callee: scopeNode.text + "::" + methodNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
private getFunctionName(node: TreeSitterNode): string {
const nameNode = findChild(node, "name");
return nameNode ? nameNode.text : "";
}
private getClassName(node: TreeSitterNode): string {
const nameNode = findChild(node, "name");
return nameNode ? nameNode.text : "";
}
private getInterfaceName(node: TreeSitterNode): string {
const nameNode = findChild(node, "name");
return nameNode ? nameNode.text : "";
}
private extractFunction(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode = findChild(node, "name");
if (!nameNode) return;
const paramsNode = findChild(node, "formal_parameters");
const params = extractParams(paramsNode);
const returnType = extractReturnType(node);
functions.push({
name: nameNode.text,
lineRange: [node.startPosition.row + 1, node.endPosition.row + 1],
params,
returnType,
});
}
private extractClass(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
): void {
const name = this.getClassName(node);
if (!name) return;
const methods: string[] = [];
const properties: string[] = [];
const declList = findChild(node, "declaration_list");
if (declList) {
this.extractDeclarationList(declList, methods, properties, functions);
}
classes.push({
name,
lineRange: [node.startPosition.row + 1, node.endPosition.row + 1],
methods,
properties,
});
}
private extractInterface(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
): void {
const name = this.getInterfaceName(node);
if (!name) return;
const methods: string[] = [];
const properties: string[] = [];
const declList = findChild(node, "declaration_list");
if (declList) {
// Interface methods are method_declaration nodes (no bodies, just signatures)
const methodDecls = findChildren(declList, "method_declaration");
for (const methodDecl of methodDecls) {
const methodName = findChild(methodDecl, "name");
if (methodName) {
methods.push(methodName.text);
}
}
}
classes.push({
name,
lineRange: [node.startPosition.row + 1, node.endPosition.row + 1],
methods,
properties,
});
}
/**
* Extract methods and properties from a class `declaration_list`.
* Also pushes each method into the top-level functions array.
*/
private extractDeclarationList(
declList: TreeSitterNode,
methods: string[],
properties: string[],
functions: StructuralAnalysis["functions"],
): void {
for (let i = 0; i < declList.childCount; i++) {
const member = declList.child(i);
if (!member) continue;
if (member.type === "method_declaration") {
const nameNode = findChild(member, "name");
if (nameNode) {
methods.push(nameNode.text);
// Also add to functions array
const paramsNode = findChild(member, "formal_parameters");
const params = extractParams(paramsNode);
const returnType = extractReturnType(member);
functions.push({
name: nameNode.text,
lineRange: [member.startPosition.row + 1, member.endPosition.row + 1],
params,
returnType,
});
}
} else if (member.type === "property_declaration") {
// Extract property name from property_element -> variable_name
const propElement = findChild(member, "property_element");
if (propElement) {
const varName = findChild(propElement, "variable_name");
if (varName) {
// Get just the name part without $
const dollarChild = findChild(varName, "name");
if (dollarChild) {
properties.push(dollarChild.text);
} else {
// Fallback: use the full text and strip $
properties.push(varName.text.replace(/^\$/, ""));
}
}
}
}
}
}
/**
* Extract imports from a `namespace_use_declaration` node.
*
* Handles:
* - Simple: `use App\Models\User;`
* - Aliased: `use App\Contracts\Repository as Repo;`
* - Grouped: `use App\Models\{User, Post};`
*/
private extractUseDeclaration(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
// Check for grouped use: `use Namespace\{A, B};`
const useGroup = findChild(node, "namespace_use_group");
if (useGroup) {
// Reconstruct the prefix from the namespace_name preceding the group
const nsName = findChild(node, "namespace_name");
const prefix = nsName ? nsName.text : "";
const clauses = findChildren(useGroup, "namespace_use_clause");
const specifiers: string[] = [];
for (const clause of clauses) {
const name = extractUseName(clause, prefix);
specifiers.push(lastSegment(name));
}
const source = prefix
? prefix + "\\{" + specifiers.join(", ") + "}"
: specifiers.join(", ");
imports.push({
source,
specifiers,
lineNumber: node.startPosition.row + 1,
});
return;
}
// Simple or aliased use declaration
const clauses = findChildren(node, "namespace_use_clause");
for (const clause of clauses) {
const fqn = extractUseName(clause, "");
const specifier = lastSegment(fqn);
imports.push({
source: fqn,
specifiers: [specifier],
lineNumber: node.startPosition.row + 1,
});
}
}
}
@@ -0,0 +1,353 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Extract parameter names from a Python `parameters` node.
*
* Handles: identifier (plain), typed_parameter, default_parameter,
* typed_default_parameter, list_splat_pattern (*args),
* dictionary_splat_pattern (**kwargs).
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
for (let i = 0; i < paramsNode.childCount; i++) {
const child = paramsNode.child(i);
if (!child) continue;
switch (child.type) {
case "identifier":
// Skip `self` and `cls` — they are implicit, not real parameters
if (child.text !== "self" && child.text !== "cls") {
params.push(child.text);
}
break;
case "typed_parameter": {
const ident = findChild(child, "identifier");
if (ident && ident.text !== "self" && ident.text !== "cls") {
params.push(ident.text);
}
break;
}
case "default_parameter": {
const ident = findChild(child, "identifier");
if (ident && ident.text !== "self" && ident.text !== "cls") {
params.push(ident.text);
}
break;
}
case "typed_default_parameter": {
const ident = findChild(child, "identifier");
if (ident && ident.text !== "self" && ident.text !== "cls") {
params.push(ident.text);
}
break;
}
case "list_splat_pattern": {
const ident = findChild(child, "identifier");
if (ident) params.push("*" + ident.text);
break;
}
case "dictionary_splat_pattern": {
const ident = findChild(child, "identifier");
if (ident) params.push("**" + ident.text);
break;
}
}
}
return params;
}
/**
* Extract the return type annotation from a function_definition node.
* Python AST has a `return_type` field (the `type` node after `->`) on function_definition.
*/
function extractReturnType(node: TreeSitterNode): string | undefined {
const returnType = node.childForFieldName("return_type");
if (returnType) {
return returnType.text;
}
return undefined;
}
/**
* Unwrap a `decorated_definition` to get the inner definition.
* If the node is not a decorated_definition, returns the node itself.
*/
function unwrapDecorated(node: TreeSitterNode): TreeSitterNode {
if (node.type === "decorated_definition") {
const inner =
findChild(node, "function_definition") ??
findChild(node, "class_definition");
if (inner) return inner;
}
return node;
}
/**
* Python extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles functions, classes, imports, exports, and call graphs for Python code.
* Python has no formal export syntax, so all top-level function and class
* definitions are treated as exports.
*/
export class PythonExtractor implements LanguageExtractor {
readonly languageIds = ["python"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
// Unwrap decorated definitions to get the inner node
const inner = unwrapDecorated(node);
switch (inner.type) {
case "function_definition":
this.extractFunction(inner, functions);
// Top-level functions are exports in Python
this.addExport(inner, node, exports);
break;
case "class_definition":
this.extractClass(inner, classes);
// Top-level classes are exports in Python
this.addExport(inner, node, exports);
break;
case "import_statement":
this.extractImport(inner, imports);
break;
case "import_from_statement":
this.extractFromImport(inner, imports);
break;
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering function/method definitions
if (node.type === "function_definition") {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
}
// Extract call expressions
if (node.type === "call") {
const calleeNode = node.children.find(
(c) =>
c.type === "identifier" ||
c.type === "attribute",
);
if (calleeNode && functionStack.length > 0) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: calleeNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
private extractFunction(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
const returnType = extractReturnType(node);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
}
private extractClass(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
for (let i = 0; i < body.childCount; i++) {
const member = body.child(i);
if (!member) continue;
// Methods: function_definition or decorated_definition wrapping a function_definition
const innerMember = unwrapDecorated(member);
if (innerMember.type === "function_definition") {
const methodName = innerMember.childForFieldName("name");
if (methodName) methods.push(methodName.text);
}
// Properties: type-annotated assignments at class body level
// e.g., `name: str` or `value: int = 0`
if (member.type === "expression_statement") {
const assignment = findChild(member, "assignment");
if (assignment) {
// Check if this is a type-annotated class-level assignment (has `:` child = type annotation)
const typeNode = findChild(assignment, "type");
const nameIdent = findChild(assignment, "identifier");
if (typeNode && nameIdent) {
properties.push(nameIdent.text);
}
}
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
}
private extractImport(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
// `import os` or `import os.path`
// Can have multiple: `import os, sys`
const dottedNames = findChildren(node, "dotted_name");
const aliasedImports = findChildren(node, "aliased_import");
for (const dn of dottedNames) {
imports.push({
source: dn.text,
specifiers: [dn.text],
lineNumber: node.startPosition.row + 1,
});
}
for (const ai of aliasedImports) {
const dottedName = findChild(ai, "dotted_name");
const alias = ai.children.find(
(c) => c.type === "identifier",
);
if (dottedName) {
imports.push({
source: dottedName.text,
specifiers: [alias ? alias.text : dottedName.text],
lineNumber: node.startPosition.row + 1,
});
}
}
}
private extractFromImport(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
// `from pathlib import Path` or `from typing import Optional, List`
const moduleNode = node.childForFieldName("module_name");
const source = moduleNode ? moduleNode.text : "";
const moduleNodeId = moduleNode?.id;
const specifiers: string[] = [];
// Collect dotted_name specifiers (non-aliased)
// Skip the module_name dotted_name (compare by node id, not reference)
const allDottedNames = findChildren(node, "dotted_name");
for (const dn of allDottedNames) {
if (dn.id === moduleNodeId) continue;
specifiers.push(dn.text);
}
// Collect aliased imports: `from foo import bar as baz`
const aliasedImports = findChildren(node, "aliased_import");
for (const ai of aliasedImports) {
// The alias identifier follows the `as` keyword
const alias = ai.children.find(
(c) => c.type === "identifier",
);
if (alias) {
specifiers.push(alias.text);
}
}
// Handle wildcard imports: `from os import *`
if (findChild(node, "wildcard_import")) {
specifiers.push("*");
}
imports.push({
source,
specifiers,
lineNumber: node.startPosition.row + 1,
});
}
private addExport(
inner: TreeSitterNode,
outer: TreeSitterNode,
exports: StructuralAnalysis["exports"],
): void {
const nameNode = inner.childForFieldName("name");
if (nameNode) {
exports.push({
name: nameNode.text,
lineNumber: outer.startPosition.row + 1,
});
}
}
}
@@ -0,0 +1,420 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Set of method names that Ruby uses for imports.
* These are handled separately from regular call graph entries.
*/
const IMPORT_METHODS = new Set(["require", "require_relative"]);
/**
* Set of method names that define class properties (attr_* macros).
* Their arguments are symbols that become accessor methods / properties.
*/
const ATTR_METHODS = new Set(["attr_accessor", "attr_reader", "attr_writer"]);
/**
* Extract parameter names from a Ruby `method_parameters` node.
*
* Handles: identifier (plain), optional_parameter (with default),
* splat_parameter (*args), hash_splat_parameter (**kwargs),
* block_parameter (&block).
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
for (let i = 0; i < paramsNode.childCount; i++) {
const child = paramsNode.child(i);
if (!child) continue;
switch (child.type) {
case "identifier":
params.push(child.text);
break;
case "optional_parameter": {
const ident = child.childForFieldName("name");
if (ident) params.push(ident.text);
break;
}
case "splat_parameter": {
const ident = child.childForFieldName("name");
if (ident) params.push("*" + ident.text);
break;
}
case "hash_splat_parameter": {
const ident = child.childForFieldName("name");
if (ident) params.push("**" + ident.text);
break;
}
case "block_parameter": {
const ident = child.childForFieldName("name");
if (ident) params.push("&" + ident.text);
break;
}
}
}
return params;
}
/**
* Extract property names from attr_accessor/attr_reader/attr_writer calls.
* These calls take symbol arguments like `:name, :email`.
*/
function extractAttrProperties(callNode: TreeSitterNode): string[] {
const properties: string[] = [];
const args = callNode.childForFieldName("arguments");
if (!args) return properties;
for (let i = 0; i < args.childCount; i++) {
const child = args.child(i);
if (child && child.type === "simple_symbol") {
// Strip leading colon from `:name` -> `name`
properties.push(child.text.slice(1));
}
}
return properties;
}
/**
* Extract the string value from a Ruby string node.
* Ruby strings have a `string_content` child containing the unquoted value.
*/
function getStringContent(node: TreeSitterNode): string {
const content = findChild(node, "string_content");
if (content) return content.text;
// Fallback: strip surrounding quotes
return node.text.replace(/^['"`]|['"`]$/g, "");
}
/**
* Ruby extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles methods, classes, modules, require imports, and call graphs
* for Ruby source code.
*
* Ruby-specific mapping decisions:
* - Both `class` and `module` nodes are mapped to the `classes` array.
* - `singleton_method` (def self.foo) is prefixed with "self." in the name.
* - `attr_accessor`/`attr_reader`/`attr_writer` define properties on classes.
* - `require` and `require_relative` calls are mapped to imports.
* - All top-level definitions (classes, modules, methods) are treated as exports,
* since Ruby has no formal export syntax.
*/
export class RubyExtractor implements LanguageExtractor {
readonly languageIds = ["ruby"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
switch (node.type) {
case "method":
this.extractMethod(node, functions);
exports.push({
name: this.getMethodName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "singleton_method":
this.extractSingletonMethod(node, functions);
exports.push({
name: "self." + this.getSingletonMethodName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "class":
this.extractClass(node, classes, functions);
exports.push({
name: this.getClassName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "module":
this.extractModule(node, classes, functions);
exports.push({
name: this.getModuleName(node),
lineNumber: node.startPosition.row + 1,
});
break;
case "call":
this.extractTopLevelCall(node, imports);
break;
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering method definitions
if (node.type === "method") {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
} else if (node.type === "singleton_method") {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push("self." + nameNode.text);
pushedName = true;
}
}
// Extract call expressions (but not imports or attr_* macros)
if (node.type === "call") {
const methodNode = node.childForFieldName("method");
if (methodNode && functionStack.length > 0) {
const methodName = methodNode.text;
// Skip require/require_relative (imports) and attr_* macros
if (!IMPORT_METHODS.has(methodName) && !ATTR_METHODS.has(methodName)) {
const receiverNode = node.childForFieldName("receiver");
const callee = receiverNode
? receiverNode.text + "." + methodName
: methodName;
entries.push({
caller: functionStack[functionStack.length - 1],
callee,
lineNumber: node.startPosition.row + 1,
});
}
}
}
// Ruby bare method calls without arguments (e.g., `setup`) are parsed as
// `identifier` nodes inside `body_statement`, not as `call` nodes.
// Treat them as calls when inside a function context.
if (
node.type === "identifier" &&
node.parent?.type === "body_statement" &&
functionStack.length > 0
) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: node.text,
lineNumber: node.startPosition.row + 1,
});
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
private getMethodName(node: TreeSitterNode): string {
const nameNode = node.childForFieldName("name");
return nameNode ? nameNode.text : "";
}
private getSingletonMethodName(node: TreeSitterNode): string {
const nameNode = node.childForFieldName("name");
return nameNode ? nameNode.text : "";
}
private getClassName(node: TreeSitterNode): string {
const nameNode = node.childForFieldName("name");
if (!nameNode) return "";
// Can be `constant` ("Foo") or `scope_resolution` ("Foo::Bar")
return nameNode.text;
}
private getModuleName(node: TreeSitterNode): string {
const nameNode = node.childForFieldName("name");
return nameNode ? nameNode.text : "";
}
private extractMethod(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
});
}
private extractSingletonMethod(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
functions.push({
name: "self." + nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
});
}
private extractClass(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
): void {
const name = this.getClassName(node);
if (!name) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
this.extractClassBody(body, methods, properties, functions);
}
classes.push({
name,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
}
private extractModule(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
): void {
const name = this.getModuleName(node);
if (!name) return;
const methods: string[] = [];
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body) {
this.extractClassBody(body, methods, properties, functions);
}
classes.push({
name,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
}
/**
* Extract methods and properties from a class/module body_statement.
* Also pushes each method into the top-level functions array.
*/
private extractClassBody(
body: TreeSitterNode,
methods: string[],
properties: string[],
functions: StructuralAnalysis["functions"],
): void {
for (let i = 0; i < body.childCount; i++) {
const member = body.child(i);
if (!member) continue;
if (member.type === "method") {
const nameNode = member.childForFieldName("name");
if (nameNode) {
methods.push(nameNode.text);
this.extractMethod(member, functions);
}
} else if (member.type === "singleton_method") {
const nameNode = member.childForFieldName("name");
if (nameNode) {
methods.push("self." + nameNode.text);
this.extractSingletonMethod(member, functions);
}
} else if (member.type === "call") {
// Check for attr_accessor/attr_reader/attr_writer
const methodNode = member.childForFieldName("method");
if (methodNode && ATTR_METHODS.has(methodNode.text)) {
properties.push(...extractAttrProperties(member));
}
}
}
}
/**
* Handle top-level call nodes: extract require/require_relative as imports.
*/
private extractTopLevelCall(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const methodNode = node.childForFieldName("method");
if (!methodNode) return;
if (IMPORT_METHODS.has(methodNode.text)) {
const args = node.childForFieldName("arguments");
if (!args) return;
// The first argument is typically the string source
const firstArg = findChild(args, "string");
if (firstArg) {
const source = getStringContent(firstArg);
imports.push({
source,
specifiers: [source],
lineNumber: node.startPosition.row + 1,
});
}
}
}
}
@@ -0,0 +1,509 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Extract parameter names from a Rust `parameters` node.
*
* Each `parameter` child has a `pattern` field (identifier) and a `type` field.
* `self_parameter` nodes (&self, &mut self, self) are skipped since they are
* implicit receivers, not user-facing parameters.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
for (let i = 0; i < paramsNode.childCount; i++) {
const child = paramsNode.child(i);
if (!child) continue;
if (child.type === "parameter") {
const pattern = child.childForFieldName("pattern");
if (pattern) {
params.push(pattern.text);
}
}
// Skip self_parameter — it's the receiver, not a real parameter
}
return params;
}
/**
* Extract the return type from a function_item node.
*
* In tree-sitter-rust, the return type is accessed via the `return_type` named
* field on function_item. The field value is the type node itself (e.g.
* primitive_type "bool", type_identifier "Self").
*/
function extractReturnType(node: TreeSitterNode): string | undefined {
const returnType = node.childForFieldName("return_type");
if (returnType) {
return returnType.text;
}
return undefined;
}
/**
* Check if a node has a `visibility_modifier` child whose text starts with "pub".
* Covers `pub`, `pub(crate)`, `pub(super)`, etc.
*/
function isPublic(node: TreeSitterNode): boolean {
const visMod = findChild(node, "visibility_modifier");
return visMod !== null && visMod.text.startsWith("pub");
}
/**
* Recursively extract the path portion of a scoped_identifier.
*
* `scoped_identifier` nests: `std::collections::HashMap` is
* scoped_identifier(path: scoped_identifier(path: identifier "std", name: identifier "collections"), name: identifier "HashMap")
*
* This function collects all path segments into a flat "a::b::c" string,
* excluding the final `name` (which is the imported specifier).
*/
function extractScopedPath(node: TreeSitterNode): { path: string; name: string } {
if (node.type === "scoped_identifier") {
const pathNode = node.childForFieldName("path");
const nameNode = node.childForFieldName("name");
const name = nameNode ? nameNode.text : "";
const path = pathNode ? pathNode.text : "";
return { path, name };
}
// Bare identifier: `use foo;`
return { path: "", name: node.text };
}
/**
* Rust extractor for tree-sitter structural analysis and call graph extraction.
*
* Handles functions, structs, enums, traits, impl blocks, use declarations,
* visibility-based exports, and call graphs for Rust source code.
*
* Rust-specific mapping decisions:
* - Structs, enums, and traits are mapped to the `classes` array.
* - Methods inside `impl` blocks are stored as functions and also listed
* in the corresponding struct/enum's `methods` array.
* - Trait method signatures (function_signature_item) are listed in the
* trait's `methods` array.
* - Exports are determined by the `pub` visibility modifier.
* - Enum variants are extracted as `properties` of the enum class entry.
*/
export class RustExtractor implements LanguageExtractor {
readonly languageIds = ["rust"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
// Track methods per impl type so we can attach them to structs/enums
const methodsByType = new Map<string, string[]>();
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
switch (node.type) {
case "function_item":
this.extractFunction(node, functions, exports);
break;
case "struct_item":
this.extractStruct(node, classes, exports);
break;
case "enum_item":
this.extractEnum(node, classes, exports);
break;
case "trait_item":
this.extractTrait(node, classes, exports);
break;
case "impl_item":
this.extractImpl(node, functions, exports, methodsByType);
break;
case "use_declaration":
this.extractUseDeclaration(node, imports);
break;
}
}
// Attach collected methods to their corresponding structs/enums/traits
for (const cls of classes) {
const methods = methodsByType.get(cls.name);
if (methods) {
cls.methods.push(...methods);
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
let pushedName = false;
// Track entering function_item declarations
if (node.type === "function_item") {
const nameNode = node.childForFieldName("name");
if (nameNode) {
functionStack.push(nameNode.text);
pushedName = true;
}
}
// Extract call expressions
if (node.type === "call_expression") {
if (functionStack.length > 0) {
const callee = this.extractCalleeName(node);
if (callee) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee,
lineNumber: node.startPosition.row + 1,
});
}
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private helpers ----
/**
* Extract the callee name from a call_expression.
*
* Handles:
* - Plain function call: `check_port(x)` -> "check_port"
* - Method call via field_expression: `self.validate()` -> "self.validate"
* - Scoped call: `Vec::new()` -> "Vec::new"
*/
private extractCalleeName(callNode: TreeSitterNode): string | null {
const funcNode = callNode.child(0);
if (!funcNode) return null;
if (funcNode.type === "identifier") {
return funcNode.text;
}
if (funcNode.type === "field_expression") {
// e.g., self.validate or obj.method
const field = funcNode.childForFieldName("field");
const value = funcNode.childForFieldName("value");
if (field && value) {
return value.text + "." + field.text;
}
}
if (funcNode.type === "scoped_identifier") {
// e.g., Vec::new
return funcNode.text;
}
// Fallback: use the full text of the function child
return funcNode.text;
}
private extractFunction(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const paramsNode = node.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
const returnType = extractReturnType(node);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
if (isPublic(node)) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractStruct(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body && body.type === "field_declaration_list") {
const fields = findChildren(body, "field_declaration");
for (const field of fields) {
const fieldName = findChild(field, "field_identifier");
if (fieldName) {
properties.push(fieldName.text);
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods: [], // Methods are attached later from methodsByType
properties,
});
if (isPublic(node)) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractEnum(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const properties: string[] = [];
const body = node.childForFieldName("body");
if (body && body.type === "enum_variant_list") {
const variants = findChildren(body, "enum_variant");
for (const variant of variants) {
const variantName = variant.childForFieldName("name");
if (variantName) {
properties.push(variantName.text);
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods: [], // Methods are attached later if there's an impl block
properties,
});
if (isPublic(node)) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractTrait(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
exports: StructuralAnalysis["exports"],
): void {
const nameNode = node.childForFieldName("name");
if (!nameNode) return;
const methods: string[] = [];
const body = findChild(node, "declaration_list");
if (body) {
// Trait bodies contain function_signature_item for method declarations
const sigs = findChildren(body, "function_signature_item");
for (const sig of sigs) {
const sigName = findChild(sig, "identifier");
if (sigName) {
methods.push(sigName.text);
}
}
// Also handle default method implementations (function_item)
const fns = findChildren(body, "function_item");
for (const fn of fns) {
const fnName = fn.childForFieldName("name");
if (fnName) {
methods.push(fnName.text);
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties: [],
});
if (isPublic(node)) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
}
}
private extractImpl(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
methodsByType: Map<string, string[]>,
): void {
const typeNode = node.childForFieldName("type");
const typeName = typeNode ? typeNode.text : null;
const body = node.childForFieldName("body");
if (!body) return;
const fns = findChildren(body, "function_item");
for (const fn of fns) {
const nameNode = fn.childForFieldName("name");
if (!nameNode) continue;
const paramsNode = fn.childForFieldName("parameters");
const params = extractParams(paramsNode ?? null);
const returnType = extractReturnType(fn);
functions.push({
name: nameNode.text,
lineRange: [
fn.startPosition.row + 1,
fn.endPosition.row + 1,
],
params,
returnType,
});
// Track method association with the impl type
if (typeName) {
if (!methodsByType.has(typeName)) {
methodsByType.set(typeName, []);
}
methodsByType.get(typeName)!.push(nameNode.text);
}
// pub methods inside impl blocks are exports
if (isPublic(fn)) {
exports.push({
name: nameNode.text,
lineNumber: fn.startPosition.row + 1,
});
}
}
}
private extractUseDeclaration(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const argument = node.childForFieldName("argument");
if (!argument) return;
switch (argument.type) {
case "identifier":
// `use foo;`
imports.push({
source: argument.text,
specifiers: [argument.text],
lineNumber: node.startPosition.row + 1,
});
break;
case "scoped_identifier": {
// `use std::collections::HashMap;`
const { path, name } = extractScopedPath(argument);
imports.push({
source: path,
specifiers: [name],
lineNumber: node.startPosition.row + 1,
});
break;
}
case "scoped_use_list": {
// `use std::io::{self, Read, Write};`
const pathNode = argument.childForFieldName("path");
const listNode = argument.childForFieldName("list");
const source = pathNode ? pathNode.text : "";
const specifiers: string[] = [];
if (listNode) {
for (let j = 0; j < listNode.childCount; j++) {
const ch = listNode.child(j);
if (!ch) continue;
if (ch.type === "self" || ch.type === "identifier") {
specifiers.push(ch.text);
} else if (ch.type === "scoped_identifier") {
// Nested scoped identifier inside a use list
specifiers.push(ch.text);
}
}
}
imports.push({
source,
specifiers,
lineNumber: node.startPosition.row + 1,
});
break;
}
case "use_wildcard": {
// `use std::prelude::*;`
// The path is the scoped_identifier child
const scopedId = findChild(argument, "scoped_identifier");
const source = scopedId ? scopedId.text : "";
imports.push({
source,
specifiers: ["*"],
lineNumber: node.startPosition.row + 1,
});
break;
}
default: {
// Fallback for any unhandled pattern
imports.push({
source: argument.text,
specifiers: [argument.text],
lineNumber: node.startPosition.row + 1,
});
break;
}
}
}
}
@@ -0,0 +1,19 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
// Re-export the tree-sitter Node type for use by extractors
export type TreeSitterNode = import("web-tree-sitter").Node;
/**
* Language-specific extractor that maps a tree-sitter AST
* to the common StructuralAnalysis / CallGraphEntry types.
*/
export interface LanguageExtractor {
/** Language IDs this extractor handles (must match LanguageConfig.id) */
languageIds: string[];
/** Extract functions, classes, imports, exports from the root AST node */
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis;
/** Extract caller→callee relationships from the root AST node */
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[];
}
@@ -0,0 +1,483 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { getStringValue } from "./base-extractor.js";
/**
* Extract parameter names from a formal_parameters node.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
for (let i = 0; i < paramsNode.childCount; i++) {
const child = paramsNode.child(i);
if (!child) continue;
if (
child.type === "required_parameter" ||
child.type === "optional_parameter"
) {
const ident =
child.childForFieldName("pattern") ??
child.childForFieldName("name");
if (ident) {
params.push(ident.text);
} else {
// Fallback: first identifier child
for (let j = 0; j < child.childCount; j++) {
const c = child.child(j);
if (c && c.type === "identifier") {
params.push(c.text);
break;
}
}
}
} else if (child.type === "identifier") {
// JavaScript parameters (no type annotation)
params.push(child.text);
} else if (
child.type === "rest_pattern" ||
child.type === "rest_element"
) {
const ident = child.children.find(
(c) => c.type === "identifier",
);
if (ident) params.push("..." + ident.text);
}
}
return params;
}
/**
* Extract return type annotation from a function-like node.
*/
function extractReturnType(
node: TreeSitterNode,
): string | undefined {
const typeAnnotation = node.childForFieldName("return_type");
if (typeAnnotation && typeAnnotation.type === "type_annotation") {
const text = typeAnnotation.text;
return text.startsWith(":") ? text.slice(1).trim() : text;
}
return undefined;
}
/**
* Extract import specifiers from an import_clause node.
*/
function extractImportSpecifiers(
importClause: TreeSitterNode,
): string[] {
const specifiers: string[] = [];
for (let i = 0; i < importClause.childCount; i++) {
const child = importClause.child(i);
if (!child) continue;
if (child.type === "named_imports") {
for (let j = 0; j < child.childCount; j++) {
const spec = child.child(j);
if (spec && spec.type === "import_specifier") {
const alias = spec.childForFieldName("alias");
const name = spec.childForFieldName("name");
specifiers.push(
alias ? alias.text : name ? name.text : spec.text,
);
}
}
} else if (child.type === "namespace_import") {
const ident = child.children.find(
(c) => c.type === "identifier",
);
if (ident) specifiers.push("* as " + ident.text);
} else if (child.type === "identifier") {
// default import: import foo from '...'
specifiers.push(child.text);
}
}
return specifiers;
}
/**
* TypeScript/JavaScript extractor.
*
* Handles structural analysis and call-graph extraction for
* TypeScript and JavaScript ASTs produced by tree-sitter.
*/
export class TypeScriptExtractor implements LanguageExtractor {
readonly languageIds = ["typescript", "javascript"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
const exportedNames = new Set<string>();
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
this.processTopLevelNode(
node,
functions,
classes,
imports,
exports,
exportedNames,
);
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
const isFunctionLike =
node.type === "function_declaration" ||
node.type === "method_definition" ||
node.type === "arrow_function" ||
node.type === "function_expression";
let pushedName = false;
if (isFunctionLike) {
let name: string | undefined;
if (node.type === "function_declaration") {
name = (
node.childForFieldName("name") ??
node.children.find((c) => c.type === "identifier")
)?.text;
} else if (node.type === "method_definition") {
name = node.children.find(
(c) => c.type === "property_identifier",
)?.text;
} else if (
node.type === "arrow_function" ||
node.type === "function_expression"
) {
const parent = node.parent;
if (parent && parent.type === "variable_declarator") {
name = parent.childForFieldName("name")?.text;
}
}
if (name) {
functionStack.push(name);
pushedName = true;
}
}
if (node.type === "call_expression") {
const callee = node.childForFieldName("function");
if (callee && functionStack.length > 0) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: callee.text,
lineNumber: node.startPosition.row + 1,
});
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(rootNode);
return entries;
}
// ---- Private extraction helpers ----
private processTopLevelNode(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
imports: StructuralAnalysis["imports"],
exports: StructuralAnalysis["exports"],
exportedNames: Set<string>,
): void {
switch (node.type) {
case "function_declaration":
this.extractFunction(node, functions);
break;
case "class_declaration":
this.extractClass(node, classes);
break;
case "lexical_declaration":
case "variable_declaration":
this.extractVariableDeclarations(node, functions);
break;
case "import_statement":
this.extractImport(node, imports);
break;
case "export_statement":
this.processExportStatement(
node,
functions,
classes,
imports,
exports,
exportedNames,
);
break;
}
}
private extractFunction(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode =
node.childForFieldName("name") ??
node.children.find((c) => c.type === "identifier");
if (!nameNode) return;
const params = extractParams(
node.childForFieldName("parameters") ??
node.children.find(
(c) => c.type === "formal_parameters",
) ??
null,
);
const returnType = extractReturnType(node);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
}
private extractClass(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
): void {
const nameNode = node.children.find(
(c) =>
c.type === "type_identifier" || c.type === "identifier",
);
if (!nameNode) return;
const methods: string[] = [];
const properties: string[] = [];
const classBody = node.children.find(
(c) => c.type === "class_body",
);
if (classBody) {
for (let j = 0; j < classBody.childCount; j++) {
const member = classBody.child(j);
if (!member) continue;
if (member.type === "method_definition") {
const methodName = member.children.find(
(c) => c.type === "property_identifier",
);
if (methodName) methods.push(methodName.text);
} else if (
member.type === "public_field_definition" ||
member.type === "property_definition"
) {
const propName = member.children.find(
(c) => c.type === "property_identifier",
);
if (propName) properties.push(propName.text);
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
}
private extractVariableDeclarations(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
for (let j = 0; j < node.childCount; j++) {
const child = node.child(j);
if (!child || child.type !== "variable_declarator") continue;
const nameNode = child.childForFieldName("name");
const valueNode = child.childForFieldName("value");
if (
nameNode &&
valueNode &&
(valueNode.type === "arrow_function" ||
valueNode.type === "function_expression" ||
valueNode.type === "function")
) {
const params = extractParams(
valueNode.childForFieldName("parameters") ??
valueNode.children.find(
(c) => c.type === "formal_parameters",
) ??
null,
);
const returnType = extractReturnType(valueNode);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
}
}
}
private extractImport(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const sourceNode = node.children.find(
(c) => c.type === "string",
);
if (!sourceNode) return;
const source = getStringValue(sourceNode);
const specifiers: string[] = [];
const importClause = node.children.find(
(c) => c.type === "import_clause",
);
if (importClause) {
specifiers.push(...extractImportSpecifiers(importClause));
}
imports.push({
source,
specifiers,
lineNumber: node.startPosition.row + 1,
});
}
private processExportStatement(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
_imports: StructuralAnalysis["imports"],
exports: StructuralAnalysis["exports"],
exportedNames: Set<string>,
): void {
for (let j = 0; j < node.childCount; j++) {
const child = node.child(j);
if (!child) continue;
switch (child.type) {
case "function_declaration": {
this.extractFunction(child, functions);
const nameNode =
child.childForFieldName("name") ??
child.children.find((c) => c.type === "identifier");
if (nameNode && !exportedNames.has(nameNode.text)) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
exportedNames.add(nameNode.text);
}
break;
}
case "class_declaration": {
this.extractClass(child, classes);
const nameNode = child.children.find(
(c) =>
c.type === "type_identifier" ||
c.type === "identifier",
);
if (nameNode && !exportedNames.has(nameNode.text)) {
const isDefault = node.children.some(
(c) => c.type === "default",
);
const exportName = isDefault
? "default"
: nameNode.text;
exports.push({
name: exportName,
lineNumber: node.startPosition.row + 1,
});
exportedNames.add(exportName);
}
break;
}
case "lexical_declaration":
case "variable_declaration": {
this.extractVariableDeclarations(child, functions);
for (let k = 0; k < child.childCount; k++) {
const declarator = child.child(k);
if (
declarator &&
declarator.type === "variable_declarator"
) {
const nameNode =
declarator.childForFieldName("name");
if (
nameNode &&
!exportedNames.has(nameNode.text)
) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
exportedNames.add(nameNode.text);
}
}
}
break;
}
case "export_clause": {
for (let k = 0; k < child.childCount; k++) {
const spec = child.child(k);
if (spec && spec.type === "export_specifier") {
const alias = spec.childForFieldName("alias");
const name = spec.childForFieldName("name");
const exportName = alias
? alias.text
: name
? name.text
: spec.text;
if (!exportedNames.has(exportName)) {
exports.push({
name: exportName,
lineNumber: node.startPosition.row + 1,
});
exportedNames.add(exportName);
}
}
}
break;
}
}
}
}
}
@@ -1,4 +1,4 @@
import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js";
import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution, CallGraphEntry } from "../types.js";
import { LanguageRegistry } from "../languages/language-registry.js";
/**
@@ -65,6 +65,12 @@ export class PluginRegistry {
return plugin.resolveImports(filePath, content);
}
extractCallGraph(filePath: string, content: string): CallGraphEntry[] | null {
const plugin = this.getPluginForFile(filePath);
if (!plugin?.extractCallGraph) return null;
return plugin.extractCallGraph(filePath, content);
}
getPlugins(): AnalyzerPlugin[] {
return [...this.plugins];
}
@@ -7,6 +7,8 @@ import type {
CallGraphEntry,
} from "../types.js";
import type { LanguageConfig } from "../languages/types.js";
import type { LanguageExtractor } from "./extractors/types.js";
import { builtinExtractors } from "./extractors/index.js";
// web-tree-sitter uses CJS internally; we need createRequire for .wasm resolution
const require = createRequire(import.meta.url);
@@ -15,130 +17,6 @@ type TreeSitterParser = import("web-tree-sitter").Parser;
type TreeSitterLanguage = import("web-tree-sitter").Language;
type TreeSitterNode = import("web-tree-sitter").Node;
/**
* Recursively traverse an AST tree, calling the visitor for each node.
*/
function traverse(
node: TreeSitterNode,
visitor: (node: TreeSitterNode) => void,
): void {
visitor(node);
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) traverse(child, visitor);
}
}
/**
* Extract the string fragment (unquoted value) from a string node.
*/
function getStringValue(node: TreeSitterNode): string {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === "string_fragment") {
return child.text;
}
}
// Fallback: strip quotes
const text = node.text;
return text.replace(/^['"`]|['"`]$/g, "");
}
/**
* Extract parameter names from a formal_parameters node.
*/
function extractParams(paramsNode: TreeSitterNode | null): string[] {
if (!paramsNode) return [];
const params: string[] = [];
for (let i = 0; i < paramsNode.childCount; i++) {
const child = paramsNode.child(i);
if (!child) continue;
if (
child.type === "required_parameter" ||
child.type === "optional_parameter"
) {
const ident =
child.childForFieldName("pattern") ??
child.childForFieldName("name");
if (ident) {
params.push(ident.text);
} else {
// Fallback: first identifier child
for (let j = 0; j < child.childCount; j++) {
const c = child.child(j);
if (c && c.type === "identifier") {
params.push(c.text);
break;
}
}
}
} else if (child.type === "identifier") {
// JavaScript parameters (no type annotation)
params.push(child.text);
} else if (
child.type === "rest_pattern" ||
child.type === "rest_element"
) {
const ident = child.children.find(
(c) => c.type === "identifier",
);
if (ident) params.push("..." + ident.text);
}
}
return params;
}
/**
* Extract return type annotation from a function-like node.
*/
function extractReturnType(
node: TreeSitterNode,
): string | undefined {
const typeAnnotation = node.childForFieldName("return_type");
if (typeAnnotation && typeAnnotation.type === "type_annotation") {
const text = typeAnnotation.text;
return text.startsWith(":") ? text.slice(1).trim() : text;
}
return undefined;
}
/**
* Extract import specifiers from an import_clause node.
*/
function extractImportSpecifiers(
importClause: TreeSitterNode,
): string[] {
const specifiers: string[] = [];
for (let i = 0; i < importClause.childCount; i++) {
const child = importClause.child(i);
if (!child) continue;
if (child.type === "named_imports") {
for (let j = 0; j < child.childCount; j++) {
const spec = child.child(j);
if (spec && spec.type === "import_specifier") {
const alias = spec.childForFieldName("alias");
const name = spec.childForFieldName("name");
specifiers.push(
alias ? alias.text : name ? name.text : spec.text,
);
}
}
} else if (child.type === "namespace_import") {
const ident = child.children.find(
(c) => c.type === "identifier",
);
if (ident) specifiers.push("* as " + ident.text);
} else if (child.type === "identifier") {
// default import: import foo from '...'
specifiers.push(child.text);
}
}
return specifiers;
}
/**
* Config-driven tree-sitter plugin.
*
@@ -164,12 +42,18 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
private _extensionToLang = new Map<string, string>();
private _initialized = false;
// Language-specific extractors (keyed by language id)
private extractors = new Map<string, LanguageExtractor>();
/**
* Create a TreeSitterPlugin with the given language configs.
* Only configs that have a `treeSitter` field will be loaded.
* If no configs are provided, defaults to TypeScript and JavaScript.
*
* @param configs Language configurations to load
* @param extractors Optional language extractors; if none provided, registers TypeScriptExtractor by default
*/
constructor(configs?: LanguageConfig[]) {
constructor(configs?: LanguageConfig[], extractors?: LanguageExtractor[]) {
if (configs) {
this.configs = configs.filter((c) => c.treeSitter);
} else {
@@ -199,6 +83,29 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
}
this.languages = langs;
// Register extractors (default: all builtin extractors)
if (extractors && extractors.length > 0) {
for (const extractor of extractors) {
this.registerExtractor(extractor);
}
} else {
for (const extractor of builtinExtractors) {
this.registerExtractor(extractor);
}
}
}
registerExtractor(extractor: LanguageExtractor): void {
for (const id of extractor.languageIds) {
this.extractors.set(id, extractor);
}
}
private getExtractor(langKey: string): LanguageExtractor | null {
// tsx is a synthetic grammar key — extraction logic is identical to typescript
const key = langKey === "tsx" ? "typescript" : langKey;
return this.extractors.get(key) ?? null;
}
private languageKeyFromPath(filePath: string): string | null {
@@ -326,30 +233,20 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
return { functions: [], classes: [], imports: [], exports: [] };
}
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
const exportedNames = new Set<string>();
const langKey = this.languageKeyFromPath(filePath);
const extractor = langKey ? this.getExtractor(langKey) : null;
const root = tree.rootNode;
for (let i = 0; i < root.childCount; i++) {
const node = root.child(i);
if (!node) continue;
this.processTopLevelNode(
node,
functions,
classes,
imports,
exports,
exportedNames,
);
let result: StructuralAnalysis;
if (extractor) {
result = extractor.extractStructure(tree.rootNode);
} else {
result = { functions: [], classes: [], imports: [], exports: [] };
}
tree.delete();
parser.delete();
return { functions, classes, imports, exports };
return result;
}
resolveImports(
@@ -390,357 +287,13 @@ export class TreeSitterPlugin implements AnalyzerPlugin {
return [];
}
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walkForCalls = (node: TreeSitterNode) => {
const isFunctionLike =
node.type === "function_declaration" ||
node.type === "method_definition" ||
node.type === "arrow_function" ||
node.type === "function_expression";
let pushedName = false;
if (isFunctionLike) {
let name: string | undefined;
if (node.type === "function_declaration") {
name = (
node.childForFieldName("name") ??
node.children.find((c) => c.type === "identifier")
)?.text;
} else if (node.type === "method_definition") {
name = node.children.find(
(c) => c.type === "property_identifier",
)?.text;
} else if (
node.type === "arrow_function" ||
node.type === "function_expression"
) {
const parent = node.parent;
if (parent && parent.type === "variable_declarator") {
name = parent.childForFieldName("name")?.text;
}
}
if (name) {
functionStack.push(name);
pushedName = true;
}
}
if (node.type === "call_expression") {
const callee = node.childForFieldName("function");
if (callee && functionStack.length > 0) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee: callee.text,
lineNumber: node.startPosition.row + 1,
});
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForCalls(child);
}
if (pushedName) {
functionStack.pop();
}
};
walkForCalls(tree.rootNode);
const langKey = this.languageKeyFromPath(filePath);
const extractor = langKey ? this.getExtractor(langKey) : null;
const result = extractor ? extractor.extractCallGraph(tree.rootNode) : [];
tree.delete();
parser.delete();
return entries;
}
// ---- Private extraction helpers ----
private processTopLevelNode(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
imports: StructuralAnalysis["imports"],
exports: StructuralAnalysis["exports"],
exportedNames: Set<string>,
): void {
switch (node.type) {
case "function_declaration":
this.extractFunction(node, functions);
break;
case "class_declaration":
this.extractClass(node, classes);
break;
case "lexical_declaration":
case "variable_declaration":
this.extractVariableDeclarations(node, functions);
break;
case "import_statement":
this.extractImport(node, imports);
break;
case "export_statement":
this.processExportStatement(
node,
functions,
classes,
imports,
exports,
exportedNames,
);
break;
}
}
private extractFunction(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
const nameNode =
node.childForFieldName("name") ??
node.children.find((c) => c.type === "identifier");
if (!nameNode) return;
const params = extractParams(
node.childForFieldName("parameters") ??
node.children.find(
(c) => c.type === "formal_parameters",
) ??
null,
);
const returnType = extractReturnType(node);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
}
private extractClass(
node: TreeSitterNode,
classes: StructuralAnalysis["classes"],
): void {
const nameNode = node.children.find(
(c) =>
c.type === "type_identifier" || c.type === "identifier",
);
if (!nameNode) return;
const methods: string[] = [];
const properties: string[] = [];
const classBody = node.children.find(
(c) => c.type === "class_body",
);
if (classBody) {
for (let j = 0; j < classBody.childCount; j++) {
const member = classBody.child(j);
if (!member) continue;
if (member.type === "method_definition") {
const methodName = member.children.find(
(c) => c.type === "property_identifier",
);
if (methodName) methods.push(methodName.text);
} else if (
member.type === "public_field_definition" ||
member.type === "property_definition"
) {
const propName = member.children.find(
(c) => c.type === "property_identifier",
);
if (propName) properties.push(propName.text);
}
}
}
classes.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
methods,
properties,
});
}
private extractVariableDeclarations(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
): void {
for (let j = 0; j < node.childCount; j++) {
const child = node.child(j);
if (!child || child.type !== "variable_declarator") continue;
const nameNode = child.childForFieldName("name");
const valueNode = child.childForFieldName("value");
if (
nameNode &&
valueNode &&
(valueNode.type === "arrow_function" ||
valueNode.type === "function_expression" ||
valueNode.type === "function")
) {
const params = extractParams(
valueNode.childForFieldName("parameters") ??
valueNode.children.find(
(c) => c.type === "formal_parameters",
) ??
null,
);
const returnType = extractReturnType(valueNode);
functions.push({
name: nameNode.text,
lineRange: [
node.startPosition.row + 1,
node.endPosition.row + 1,
],
params,
returnType,
});
}
}
}
private extractImport(
node: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const sourceNode = node.children.find(
(c) => c.type === "string",
);
if (!sourceNode) return;
const source = getStringValue(sourceNode);
const specifiers: string[] = [];
const importClause = node.children.find(
(c) => c.type === "import_clause",
);
if (importClause) {
specifiers.push(...extractImportSpecifiers(importClause));
}
imports.push({
source,
specifiers,
lineNumber: node.startPosition.row + 1,
});
}
private processExportStatement(
node: TreeSitterNode,
functions: StructuralAnalysis["functions"],
classes: StructuralAnalysis["classes"],
_imports: StructuralAnalysis["imports"],
exports: StructuralAnalysis["exports"],
exportedNames: Set<string>,
): void {
for (let j = 0; j < node.childCount; j++) {
const child = node.child(j);
if (!child) continue;
switch (child.type) {
case "function_declaration": {
this.extractFunction(child, functions);
const nameNode =
child.childForFieldName("name") ??
child.children.find((c) => c.type === "identifier");
if (nameNode && !exportedNames.has(nameNode.text)) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
exportedNames.add(nameNode.text);
}
break;
}
case "class_declaration": {
this.extractClass(child, classes);
const nameNode = child.children.find(
(c) =>
c.type === "type_identifier" ||
c.type === "identifier",
);
if (nameNode && !exportedNames.has(nameNode.text)) {
const isDefault = node.children.some(
(c) => c.type === "default",
);
const exportName = isDefault
? "default"
: nameNode.text;
exports.push({
name: exportName,
lineNumber: node.startPosition.row + 1,
});
exportedNames.add(exportName);
}
break;
}
case "lexical_declaration":
case "variable_declaration": {
this.extractVariableDeclarations(child, functions);
for (let k = 0; k < child.childCount; k++) {
const declarator = child.child(k);
if (
declarator &&
declarator.type === "variable_declarator"
) {
const nameNode =
declarator.childForFieldName("name");
if (
nameNode &&
!exportedNames.has(nameNode.text)
) {
exports.push({
name: nameNode.text,
lineNumber: node.startPosition.row + 1,
});
exportedNames.add(nameNode.text);
}
}
}
break;
}
case "export_clause": {
for (let k = 0; k < child.childCount; k++) {
const spec = child.child(k);
if (spec && spec.type === "export_specifier") {
const alias = spec.childForFieldName("alias");
const name = spec.childForFieldName("name");
const exportName = alias
? alias.text
: name
? name.text
: spec.text;
if (!exportedNames.has(exportName)) {
exports.push({
name: exportName,
lineNumber: node.startPosition.row + 1,
});
exportedNames.add(exportName);
}
}
}
break;
}
}
}
return result;
}
}
@@ -196,6 +196,7 @@ Fill in batch-specific parameters below and dispatch:
> Project: `<projectName>`
> Languages: `<languages>`
> Batch index: `<batchIndex>`
> Skill directory (for bundled scripts): `<SKILL_DIR>`
> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-<batchIndex>.json`
>
> Pre-resolved import data for this batch (use this for all import edge creation — do NOT re-resolve imports from source):
@@ -0,0 +1,296 @@
#!/usr/bin/env node
/**
* extract-structure.mjs
*
* Deterministic structural extraction script for the file-analyzer agent.
* Uses PluginRegistry (TreeSitterPlugin + non-code parsers) from @understand-anything/core
* to replace the LLM-generated throwaway regex scripts in Phase 1.
*
* Usage:
* node extract-structure.mjs <input.json> <output.json>
*
* Input JSON:
* { projectRoot, batchFiles: [{path, language, sizeLines, fileCategory}], batchImportData }
*
* Output JSON:
* { scriptCompleted, filesAnalyzed, filesSkipped, results: [...] }
*/
import { createRequire } from 'node:module';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync, writeFileSync } from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// skills/understand/ -> plugin root is two dirs up
const pluginRoot = resolve(__dirname, '../..');
const require = createRequire(resolve(pluginRoot, 'package.json'));
// ---------------------------------------------------------------------------
// Resolve @understand-anything/core
// ---------------------------------------------------------------------------
let core;
try {
core = await import(require.resolve('@understand-anything/core'));
} catch {
// Fallback: direct path for installed plugin cache layouts
core = await import(resolve(pluginRoot, 'packages/core/dist/index.js'));
}
const { TreeSitterPlugin, PluginRegistry, builtinLanguageConfigs, registerAllParsers } = core;
// ---------------------------------------------------------------------------
// Argument validation
// ---------------------------------------------------------------------------
const [,, inputPath, outputPath] = process.argv;
if (!inputPath || !outputPath) {
process.stderr.write('Usage: node extract-structure.mjs <input.json> <output.json>\n');
process.exit(1);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
// Read input
const inputRaw = readFileSync(inputPath, 'utf-8');
const input = JSON.parse(inputRaw);
const { projectRoot, batchFiles, batchImportData } = input;
if (!projectRoot || !Array.isArray(batchFiles)) {
throw new Error('Invalid input: must contain projectRoot and batchFiles array');
}
// Create tree-sitter plugin with all configs that have WASM grammars
const tsConfigs = builtinLanguageConfigs.filter(c => c.treeSitter);
const tsPlugin = new TreeSitterPlugin(tsConfigs);
await tsPlugin.init();
// Create registry and register tree-sitter + all non-code parsers
const registry = new PluginRegistry();
registry.register(tsPlugin);
registerAllParsers(registry);
const results = [];
const filesSkipped = [];
for (const file of batchFiles) {
const absolutePath = join(projectRoot, file.path);
// Read file content
let content;
try {
content = readFileSync(absolutePath, 'utf-8');
} catch (err) {
filesSkipped.push(file.path);
continue;
}
// Line counts
const lines = content.split('\n');
const totalLines = lines.length;
const nonEmptyLines = lines.filter(l => l.trim().length > 0).length;
// Structural analysis via registry
let analysis = null;
try {
analysis = registry.analyzeFile(file.path, content);
} catch {
// If analysis throws, treat as degraded — still include basic metrics
}
// Call graph extraction (code files only)
let callGraph = null;
if (file.fileCategory === 'code' || file.fileCategory === 'script') {
try {
const cg = registry.extractCallGraph(file.path, content);
if (cg && cg.length > 0) {
callGraph = cg.map(entry => ({
caller: entry.caller,
callee: entry.callee,
lineNumber: entry.lineNumber,
}));
}
} catch {
// Call graph extraction failed — non-fatal
}
}
// Build result object
const result = buildResult(file, totalLines, nonEmptyLines, analysis, callGraph, batchImportData);
results.push(result);
}
// Write output
const output = {
scriptCompleted: true,
filesAnalyzed: results.length,
filesSkipped,
results,
};
writeFileSync(outputPath, JSON.stringify(output, null, 2), 'utf-8');
}
// ---------------------------------------------------------------------------
// Result builder: maps StructuralAnalysis to the expected output schema
// ---------------------------------------------------------------------------
function buildResult(file, totalLines, nonEmptyLines, analysis, callGraph, batchImportData) {
const base = {
path: file.path,
language: file.language,
fileCategory: file.fileCategory,
totalLines,
nonEmptyLines,
};
if (!analysis) {
// No parser matched — return basic metrics only
base.metrics = {};
return base;
}
const isCode = file.fileCategory === 'code' || file.fileCategory === 'script' || file.fileCategory === 'markup';
// Functions (code files)
if (analysis.functions && analysis.functions.length > 0) {
base.functions = analysis.functions.map(fn => ({
name: fn.name,
startLine: fn.lineRange[0],
endLine: fn.lineRange[1],
params: fn.params || [],
}));
}
// Classes (code files)
if (analysis.classes && analysis.classes.length > 0) {
base.classes = analysis.classes.map(cls => ({
name: cls.name,
startLine: cls.lineRange[0],
endLine: cls.lineRange[1],
methods: cls.methods || [],
properties: cls.properties || [],
}));
}
// Exports (code files)
if (analysis.exports && analysis.exports.length > 0) {
base.exports = analysis.exports.map(exp => ({
name: exp.name,
line: exp.lineNumber,
isDefault: false,
}));
}
// Non-code structural data: pass through directly
if (analysis.sections && analysis.sections.length > 0) {
base.sections = analysis.sections.map(s => ({
heading: s.name,
level: s.level,
line: s.lineRange[0],
}));
}
if (analysis.definitions && analysis.definitions.length > 0) {
base.definitions = analysis.definitions.map(d => ({
name: d.name,
kind: d.kind,
fields: d.fields || [],
startLine: d.lineRange[0],
endLine: d.lineRange[1],
}));
}
if (analysis.services && analysis.services.length > 0) {
base.services = analysis.services.map(s => ({
name: s.name,
image: s.image,
ports: s.ports || [],
...(s.lineRange ? { startLine: s.lineRange[0], endLine: s.lineRange[1] } : {}),
}));
}
if (analysis.endpoints && analysis.endpoints.length > 0) {
base.endpoints = analysis.endpoints.map(e => ({
method: e.method,
path: e.path,
startLine: e.lineRange[0],
endLine: e.lineRange[1],
}));
}
if (analysis.steps && analysis.steps.length > 0) {
base.steps = analysis.steps.map(s => ({
name: s.name,
startLine: s.lineRange[0],
endLine: s.lineRange[1],
}));
}
if (analysis.resources && analysis.resources.length > 0) {
base.resources = analysis.resources.map(r => ({
name: r.name,
kind: r.kind,
startLine: r.lineRange[0],
endLine: r.lineRange[1],
}));
}
// Call graph
if (callGraph && callGraph.length > 0) {
base.callGraph = callGraph;
}
// Metrics
const metrics = {};
// Import count from batchImportData (pre-resolved by project scanner)
const importPaths = batchImportData?.[file.path];
if (importPaths) {
metrics.importCount = importPaths.length;
} else if (analysis.imports) {
metrics.importCount = analysis.imports.length;
}
if (analysis.exports) {
metrics.exportCount = analysis.exports.length;
}
if (analysis.functions) {
metrics.functionCount = analysis.functions.length;
}
if (analysis.classes) {
metrics.classCount = analysis.classes.length;
}
if (analysis.sections) {
metrics.sectionCount = analysis.sections.length;
}
if (analysis.definitions) {
metrics.definitionCount = analysis.definitions.length;
}
if (analysis.services) {
metrics.serviceCount = analysis.services.length;
}
if (analysis.endpoints) {
metrics.endpointCount = analysis.endpoints.length;
}
if (analysis.steps) {
metrics.stepCount = analysis.steps.length;
}
if (analysis.resources) {
metrics.resourceCount = analysis.resources.length;
}
base.metrics = metrics;
return base;
}
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
try {
await main();
} catch (err) {
process.stderr.write(`extract-structure.mjs failed: ${err.message}\n${err.stack}\n`);
process.exit(1);
}