Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ teamai codebase --lint # health check
The graph stores components, interfaces, configs, and cross-repo import edges. `teamai recall` uses it for graph-boosted re-ranking.
When a recall hit comes from a codebase page, the result includes a `Sources:` line listing the relevant source file paths — giving agents a direct starting point for code changes instead of re-exploring the repo.

Edges come from two tracks that run together, with AST results taking precedence on overlap:

- **AST track** (TypeScript/JavaScript, Python, Go): a WASM [tree-sitter](https://tree-sitter.github.io/) parser resolves `import`/`require`, call sites, and TS `implements` clauses to precise file-to-file `DEPENDS_ON` / `REFERENCES` / `IMPLEMENTS` edges (tagged `code-ast`, with confidence weights).
- **Heuristic track** (all languages, including Java/Rust): regex-based extraction (tagged `code-heuristic`), which also covers languages the AST track does not.

The WASM parser is a pure-JavaScript dependency — no native toolchain is required. If it fails to load for any reason, extraction falls back to the heuristic track and records an `AST_UNAVAILABLE` gap. Set `TEAMAI_SKIP_AST=1` to force heuristic-only extraction.

## Commands

| Command | Description |
Expand Down
7 changes: 7 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ teamai codebase --lint # 健康检查
图谱存储组件、接口、配置和跨仓库依赖边。`teamai recall` 利用图谱进行增强排名。
当召回命中 codebase 页面时,结果会附带一行 `Sources:`,列出相关源文件路径,供 agent 直接作为代码改动的入口,无需重新探索代码库。

依赖边来自两条并行的提取轨道,重叠时以 AST 结果优先:

- **AST 轨**(TypeScript/JavaScript、Python、Go):使用 WASM 版 [tree-sitter](https://tree-sitter.github.io/) 解析器,将 `import`/`require`、调用点、以及 TS `implements` 子句解析为精确的文件到文件 `DEPENDS_ON` / `REFERENCES` / `IMPLEMENTS` 边(标记为 `code-ast`,带置信度权重)。
- **启发式轨**(所有语言,含 Java/Rust):基于正则的提取(标记为 `code-heuristic`),同时覆盖 AST 轨未支持的语言。

WASM 解析器是纯 JavaScript 依赖,无需任何原生编译工具链。若因任何原因加载失败,提取会降级到启发式轨并记录一条 `AST_UNAVAILABLE` gap。设置 `TEAMAI_SKIP_AST=1` 可强制仅使用启发式提取。

## 命令一览

| 命令 | 说明 |
Expand Down
3 changes: 3 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,7 @@ Configurable environment variables:
| `TEAMAI_SKILL_DOWNLOAD_HOSTS` | Allowlist of hosts for skill `download_url` (empty = allow all) |
| `TEAMAI_ALLOW_SANDBOX_REPORT` | Set to `1` to force report/sync inside a CloudStudio sandbox (see note below) |
| `TEAMAI_DISABLE_REMOTE_CMD` | Set to `1` to reject server-pushed `uninstall_teamai`, `install_hook_rule`, and `uninstall_hook_rule` commands (they are acked `failed`) |
| `TEAMAI_SKIP_AST` | Set to `1` to force heuristic-only code extraction, skipping the WASM tree-sitter AST track |

> **Privacy:** The install path and machine id are only hashed locally to derive `local_agent_id` — they are never reported.

Expand Down Expand Up @@ -812,6 +813,8 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich

The graph stores components, interfaces, configs, and cross-repo dependencies. `teamai recall` uses the graph for BM25 + graph-boosted ranking.

Dependency edges are extracted by two parallel tracks: a WASM tree-sitter **AST track** (TypeScript/JavaScript, Python, Go) that resolves imports, calls, and TS `implements` clauses to precise file-to-file edges (`code-ast`), and a regex **heuristic track** (all languages, `code-heuristic`) that also covers languages the AST track does not. AST results win on overlap. The AST parser needs no native toolchain; on load failure, extraction falls back to heuristics and records an `AST_UNAVAILABLE` gap. Set `TEAMAI_SKIP_AST=1` to force heuristic-only extraction.

```bash
# Graph health check
teamai codebase --lint
Expand Down
3 changes: 3 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ agent hook 规则:
| `TEAMAI_SKILL_DOWNLOAD_HOSTS` | skill `download_url` host 白名单(空 = 全部放行) |
| `TEAMAI_ALLOW_SANDBOX_REPORT` | 设为 `1` 可强制在 CloudStudio 沙箱内 report/sync(见下方说明) |
| `TEAMAI_DISABLE_REMOTE_CMD` | 设为 `1` 可拒绝服务端下发的 `uninstall_teamai`、`install_hook_rule`、`uninstall_hook_rule` 命令(会 ack `failed`) |
| `TEAMAI_SKIP_AST` | 设为 `1` 时强制仅用启发式提取,跳过 WASM tree-sitter AST 轨 |

> **隐私**:install path 和 machine id 仅在本地哈希以派生 `local_agent_id`,不会上报。

Expand Down Expand Up @@ -807,6 +808,8 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich

图谱存储组件、接口、配置和跨仓库依赖关系。`teamai recall` 利用图谱进行 BM25 + graph-boost 增强排名。

依赖边由两条并行轨道提取:WASM tree-sitter **AST 轨**(TypeScript/JavaScript、Python、Go),将 import、调用、以及 TS `implements` 子句解析为精确的文件到文件边(`code-ast`);以及正则 **启发式轨**(所有语言,`code-heuristic`),同时覆盖 AST 轨未支持的语言。重叠时 AST 结果优先。AST 解析器无需原生编译工具链;加载失败时提取会降级到启发式并记录一条 `AST_UNAVAILABLE` gap。设置 `TEAMAI_SKIP_AST=1` 可强制仅用启发式提取。

```bash
# 图谱健康检查
teamai codebase --lint
Expand Down
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
"ora": "^8.1.0",
"simple-git": "^3.27.0",
"smol-toml": "^1.3.1",
"tree-sitter-wasms": "0.1.13",
"web-tree-sitter": "0.25.10",
"yaml": "^2.6.0",
"zod": "^3.24.0"
},
Expand Down
271 changes: 271 additions & 0 deletions src/__tests__/ast-extract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { describe, it, expect, beforeEach } from 'vitest';

import type { CodeCollectedFile } from '../wiki-engine/code-knowledge/code-collector.js';
import {
astAvailable,
extractStructuralGraphAsFacts,
} from '../wiki-engine/code-knowledge/ast/index.js';
import { resetParserRegistryForTests } from '../wiki-engine/code-knowledge/ast/parser-registry.js';
import { mergeCodeFacts } from '../wiki-engine/code-knowledge/ast/merge-edges.js';
import type { CodeFact } from '../wiki-engine/code-knowledge/code-extractors.js';
import { parseEdgeProvenance, edgeProvenanceRank, edgeReason } from '../enrich-with-ai.js';

/**
* Build an in-memory collected file for AST extraction tests.
*
* repoRoot is a virtual path; import resolution relies on the in-memory
* known-files set (relativePath), so no real files need to exist on disk.
*/
function makeFile(relativePath: string, content: string): CodeCollectedFile {
const language = relativePath.endsWith('.py')
? 'python'
: relativePath.endsWith('.go')
? 'go'
: 'typescript';
return {
path: `/virtual/${relativePath}`,
relativePath,
language,
sha256: 'test',
content,
};
}

const REPO_ROOT = '/virtual';

describe('AST structural extraction (web-tree-sitter WASM)', () => {
beforeEach(() => {
resetParserRegistryForTests();
});

it('reports AST as available in this environment', () => {
expect(astAvailable()).toBe(true);
});

it('resolves a TypeScript relative import into a precise code-ast DEPENDS_ON edge', async () => {
const files = [
makeFile('src/a.ts', 'import { helper } from "./b";\nexport function run() {\n return helper();\n}\n'),
makeFile('src/b.ts', 'export function helper() {\n return 42;\n}\n'),
];

const { facts, result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

const dependsOn = result.edges.find(
(e) => e.from === 'src/a.ts' && e.to === 'src/b.ts' && e.relation === 'DEPENDS_ON',
);
expect(dependsOn).toBeDefined();
expect(dependsOn?.source).toBe('code-ast');

// The adapted CodeFact carries the resolved target and a (code-ast) marker.
const astFact = facts.find((f) => f.file === 'src/a.ts' && f.name === 'src/b.ts');
expect(astFact).toBeDefined();
expect(astFact?.kind).toBe('relation');
expect(astFact?.detail).toContain('(code-ast)');

expect(result.stats.imports).toBeGreaterThanOrEqual(1);
expect(result.stats.importsResolved).toBeGreaterThanOrEqual(1);
});

it('resolves a cross-file call into a code-ast REFERENCES edge', async () => {
const files = [
makeFile('src/a.ts', 'import { helper } from "./b";\nexport function run() {\n return helper();\n}\n'),
makeFile('src/b.ts', 'export function helper() {\n return 42;\n}\n'),
];

const { result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

const references = result.edges.find(
(e) => e.from === 'src/a.ts' && e.to === 'src/b.ts' && e.relation === 'REFERENCES',
);
expect(references).toBeDefined();
expect(references?.source).toBe('code-ast');
});

it('extracts symbols and resolves a sibling module import from Python', async () => {
// "from util import boot" captures module_name "util", which resolves
// relative to main.py's directory → sibling pkg/util.py.
const files = [
makeFile('pkg/main.py', 'from util import boot\n\nclass App:\n def start(self):\n return boot()\n'),
makeFile('pkg/util.py', 'def boot():\n return 1\n'),
];

const { result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

// class App + def start + def boot = at least 3 symbols
expect(result.stats.symbols).toBeGreaterThanOrEqual(3);
const edge = result.edges.find((e) => e.from === 'pkg/main.py' && e.to === 'pkg/util.py');
expect(edge).toBeDefined();
expect(edge?.source).toBe('code-ast');
});

it('treats module-level Python defs as exported so cross-file calls resolve to a symbol', async () => {
// Regression: tree-sitter-python has no "__export__" node, so export
// detection must fall back to module-level class/def. Without it every
// Python symbol is exported=false and symbol-level call resolution never
// fires (only the coarser file-level edge survives).
const files = [
makeFile('pkg/main.py', 'from util import boot\n\ndef run():\n return boot()\n'),
makeFile('pkg/util.py', 'def boot():\n return 1\n'),
];

const { result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

const bootSymbol = result.symbols.find((s) => s.name === 'boot' && s.file === 'pkg/util.py');
expect(bootSymbol?.exported).toBe(true);

// The call boot() in main.py resolves to boot's symbol id in util.py.
const call = result.callSites.find((c) => c.calleeText === 'boot' && c.fromFile === 'pkg/main.py');
expect(call?.resolvedTargetId).toBe('pkg/util.py#Function:boot');
expect(call?.resolvedTargetFile).toBe('pkg/util.py');
});

it('extracts symbols from Go', async () => {
const goSrc = [
'package main',
'',
'import "fmt"',
'',
'type Server struct{}',
'',
'func Run() {',
' fmt.Println("hi")',
'}',
'',
].join('\n');
const files = [makeFile('main.go', goSrc)];

const { result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

// Go: func Run + type Server = 2 symbols
expect(result.stats.symbols).toBeGreaterThanOrEqual(2);
// "fmt" is an external package import → recorded as an EXTERNAL_IMPORT gap
expect(result.gaps.some((g) => g.kind === 'EXTERNAL_IMPORT')).toBe(true);
});

it('resolves a multi-segment Python import (from a.b.c import x) to a nested file', async () => {
const files = [
makeFile('app/main.py', 'from pkg.sub.helper import boot\n\ndef run():\n return boot()\n'),
makeFile('app/pkg/sub/helper.py', 'def boot():\n return 1\n'),
];

const { result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

const edge = result.edges.find(
(e) => e.from === 'app/main.py' && e.to === 'app/pkg/sub/helper.py',
);
expect(edge).toBeDefined();
expect(edge?.relation).toBe('DEPENDS_ON');
expect(edge?.source).toBe('code-ast');
});

it('produces an IMPLEMENTS edge for a TS class implementing an imported interface', async () => {
const files = [
makeFile('src/svc.ts', 'import { IFoo } from "./iface";\n\nexport class Svc implements IFoo {\n run() {}\n}\n'),
makeFile('src/iface.ts', 'export interface IFoo {\n run(): void;\n}\n'),
];

const { result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

const impl = result.edges.find(
(e) => e.relation === 'IMPLEMENTS' && e.from === 'src/svc.ts' && e.to === 'src/iface.ts',
);
expect(impl).toBeDefined();
expect(impl?.source).toBe('code-ast');
});

it('produces an IMPLEMENTS edge for a same-file interface', async () => {
const files = [
makeFile('src/only.ts', 'export interface IBar {\n go(): void;\n}\n\nexport class Impl implements IBar {\n go() {}\n}\n'),
];

const { result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

const impl = result.edges.find((e) => e.relation === 'IMPLEMENTS' && e.from === 'src/only.ts');
expect(impl).toBeDefined();
expect(impl?.to).toBe('src/only.ts');
});

it('records unresolved external imports as gaps', async () => {
const files = [
makeFile('src/a.ts', 'import { thing } from "some-external-pkg";\nexport const x = thing;\n'),
];

const { result } = await extractStructuralGraphAsFacts({ repoRoot: REPO_ROOT, files });

expect(result.gaps.some((g) => g.kind === 'EXTERNAL_IMPORT')).toBe(true);
});

it('mergeCodeFacts lets AST relation facts win over heuristic relation facts on the same line', () => {
const astFacts: CodeFact[] = [
{
kind: 'relation',
name: 'src/b.ts',
file: 'src/a.ts',
lineStart: 1,
lineEnd: 1,
detail: 'DEPENDS_ON → src/b.ts (code-ast)',
confidence: 'EXTRACTED',
evidenceType: 'usage',
},
];
const heuristicFacts: CodeFact[] = [
{
kind: 'relation',
name: './b',
file: 'src/a.ts',
lineStart: 1,
lineEnd: 1,
detail: 'import ./b',
confidence: 'INFERRED',
evidenceType: 'usage',
},
];

const merged = mergeCodeFacts(astFacts, heuristicFacts);
const relations = merged.filter((f) => f.kind === 'relation' && f.file === 'src/a.ts' && f.lineStart === 1);
expect(relations).toHaveLength(1);
expect(relations[0]?.detail).toContain('(code-ast)');
});

describe('enrich edge provenance', () => {
it('preserves AST relation and source from a code-ast fact detail', () => {
expect(parseEdgeProvenance('REFERENCES → src/repo.ts (code-ast)')).toEqual({
relation: 'REFERENCES',
source: 'code-ast',
});
expect(parseEdgeProvenance('DEPENDS_ON → src/b.ts (code-ast)')).toEqual({
relation: 'DEPENDS_ON',
source: 'code-ast',
});
expect(parseEdgeProvenance('IMPLEMENTS → src/iface.ts (code-ast)')).toEqual({
relation: 'IMPLEMENTS',
source: 'code-ast',
});
});

it('falls back to DEPENDS_ON / code-heuristic for a raw regex fact detail', () => {
expect(parseEdgeProvenance('import { x } from "./y";')).toEqual({
relation: 'DEPENDS_ON',
source: 'code-heuristic',
});
});

it('ranks code-ast DEPENDS_ON above REFERENCES/IMPLEMENTS and heuristic (deterministic merge)', () => {
const astDepends = { relation: 'DEPENDS_ON', source: 'code-ast' as const };
const astRefs = { relation: 'REFERENCES', source: 'code-ast' as const };
const astImpl = { relation: 'IMPLEMENTS', source: 'code-ast' as const };
const heuristic = { relation: 'DEPENDS_ON', source: 'code-heuristic' as const };

expect(edgeProvenanceRank(astDepends)).toBeGreaterThan(edgeProvenanceRank(astRefs));
expect(edgeProvenanceRank(astRefs)).toBeGreaterThan(edgeProvenanceRank(astImpl));
expect(edgeProvenanceRank(astImpl)).toBeGreaterThan(edgeProvenanceRank(heuristic));
expect(edgeProvenanceRank(heuristic)).toBe(0);
});

it('edgeReason matches the resolved relation', () => {
expect(edgeReason('a', 'b', 'REFERENCES')).toBe('a references b');
expect(edgeReason('a', 'b', 'IMPLEMENTS')).toBe('a implements b');
expect(edgeReason('a', 'b', 'DEPENDS_ON')).toBe('a imports from b');
});
});
});
Loading
Loading