iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0

昨天我們確立了 15 題驗收題目。今天我們要動手建立 codebase-agent 的實體專案骨架。

很多人寫 Agent 的第一個動作是 pip install langchainpip install llama-index。但本系列的第一條鐵律是:在確定性分析引擎做完之前,不引入任何肥大的外部 AI 框架

所有分析只依賴 Python 3.11+ 原生標準函式庫:

  • 語法樹解析:ast
  • 關聯儲存:sqlite3
  • 路徑處理:pathlib
  • 唯一的開發依賴:用於跑單元測試的 pytest

專案目錄結構

首先在終端機建立骨架目錄:

codebase-agent/
├─ pyproject.toml         # 專案依賴宣告 (uv 管理)
├─ app/
│  ├─ __init__.py
│  ├─ codebase.py         # 核心層:安全邊界、AST 解析與 SQLite 儲存
│  ├─ tools.py            # 工具層:無副作用的唯讀查詢介面
│  └─ cli.py              # 介面層:人類可直接執行的 CLI
├─ data/                  # 本地 SQLite 索引存放區 (與目標 repo 完全隔離)
└─ tests/
   └─ unit/
      ├─ __init__.py
      └─ test_boundary.py # 核心安全邊界測試

pyproject.toml 中宣告極簡依賴:

[project]
name = "codebase-agent"
version = "0.1.0"
description = "Deterministic Codebase Intelligence Engine"
requires-python = ">=3.11"
dependencies = []

[dependency-groups]
dev = [
    "pytest>=8.0.0",
]

實作核心層骨架:app/codebase.py

在分析任何程式碼前,第一件事是實作「路徑安全守門員」。我們要保證任何傳進來的路徑,經過解析後絕不可能跳脫目標專案根目錄。

"""app/codebase.py - 核心靜態分析與安全邊界引擎"""
from pathlib import Path

class RepositoryBoundaryError(Exception):
    """嘗試存取 repository 外部路徑時拋出此例外"""
    pass

class CodebaseAnalyzer:
    """管理目標專案的檔案邊界、AST 解析與索引儲存"""

    def __init__(self, repo_root: str):
        # 取得目標 repo 的絕對路徑並解析 symlink
        self.repo_root = Path(repo_root).resolve()
        if not self.repo_root.exists():
            raise FileNotFoundError(f"目標目錄不存在: {self.repo_root}")

    def resolve_safe_path(self, relative_path: str) -> Path:
        """
        將相對路徑解析為絕對路徑,並強制阻斷 Path Traversal 越界存取。
        """
        # 使用 resolve() 消除內部的 '..'
        target = (self.repo_root / relative_path).resolve()

        # 核心防禦:確認目標路徑是否仍在 repo_root 的子樹下
        if not target.is_relative_to(self.repo_root):
            raise RepositoryBoundaryError(
                f"安全防護觸發:路徑 '{relative_path}' 超出專案邊界!"
            )
        return target

實作工具層介面:app/tools.py

工具層是未來提供給 LLM 進行 Tool / Function Calling 的入口。在實作具體查詢前,先將型別與規格(Signature)定義乾淨:

"""app/tools.py - 供 CLI 與 Agent 調用的唯讀工具集規格"""
from typing import List, Dict, Any
from app.codebase import CodebaseAnalyzer

class CodebaseTools:
    def __init__(self, analyzer: CodebaseAnalyzer):
        self.analyzer = analyzer

    def read_evidence(self, relative_path: str, start_line: int, end_line: int) -> str:
        """安全讀取指定檔案的特定區間程式碼作為證據"""
        safe_path = self.analyzer.resolve_safe_path(relative_path)
        if not safe_path.exists():
            raise FileNotFoundError(f"找不到檔案: {relative_path}")

        lines = safe_path.read_text(encoding="utf-8").splitlines()
        selected = lines[max(0, start_line - 1):end_line]
        return "\n".join(selected)

    def search_symbol(self, name: str) -> List[Dict[str, Any]]:
        """搜尋符號(下一階段串接 SQLite 實作)"""
        return []

實作介面層入口:app/cli.py

提供一個能直接在終端機操作的最小 CLI 骨架:

"""app/cli.py - 終端命令列介面"""
import argparse
import sys
from app.codebase import CodebaseAnalyzer

def main():
    parser = argparse.ArgumentParser(description="Codebase Intelligence CLI")
    parser.add_argument("--repo", default=".", help="目標 repository 路徑")
    subparsers = parser.add_subparsers(dest="command")

    # ping / check 指令
    subparsers.add_parser("check", help="檢查目標 repository 有效性")

    args = parser.parse_args()

    if args.command == "check":
        try:
            analyzer = CodebaseAnalyzer(args.repo)
            print(f"成功連結目標 Repository: {analyzer.repo_root}")
        except Exception as e:
            print(f"錯誤: {e}", file=sys.stderr)
            sys.exit(1)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()

寫出第一批綠燈測試:tests/unit/test_boundary.py

在接上 LLM 之前,我們必須親手驗證 Day 3 驗收清單中的第 10 題與第 11 題(安全邊界):

"""tests/unit/test_boundary.py - 驗證路徑邊界防禦機制"""
import pytest
from app.codebase import CodebaseAnalyzer, RepositoryBoundaryError
from app.tools import CodebaseTools

def test_prevent_path_traversal(tmp_path):
    """測試越界存取(如 ../secret.txt)時必須強制拋出例外"""
    repo = tmp_path / "repo"
    repo.mkdir()
    analyzer = CodebaseAnalyzer(str(repo))

    # 嘗試跳出根目錄
    with pytest.raises(RepositoryBoundaryError):
        analyzer.resolve_safe_path("../secret.txt")

    # 嘗試多層跳出
    with pytest.raises(RepositoryBoundaryError):
        analyzer.resolve_safe_path("sub/../../outside.txt")

def test_allow_valid_path(tmp_path):
    """測試合法目錄與檔案能夠被正常解析"""
    repo = tmp_path / "repo"
    repo.mkdir()
    target_file = repo / "src" / "main.py"
    target_file.parent.mkdir(parents=True)
    target_file.write_text("print('hello')", encoding="utf-8")

    analyzer = CodebaseAnalyzer(str(repo))
    resolved = analyzer.resolve_safe_path("src/main.py")
    assert resolved == target_file

def test_read_evidence_slice(tmp_path):
    """測試精準讀取指定行號程式碼"""
    repo = tmp_path / "repo"
    repo.mkdir()
    test_file = repo / "demo.py"
    test_file.write_text("line1\nline2\nline3\nline4\n", encoding="utf-8")

    analyzer = CodebaseAnalyzer(str(repo))
    tools = CodebaseTools(analyzer)

    evidence = tools.read_evidence("demo.py", start_line=2, end_line=3)
    assert evidence == "line2\nline3"

實際執行驗證

使用 uv 建立虛擬環境並執行測試:

uv run pytest tests/unit -v

今天我們完成了三件關鍵底層工作:

  1. 目錄分層清晰:核心靜態分析、工具介面與 CLI 徹底分離。
  2. 核心防護落地:透過標準函式庫將越界攻擊防禦寫死在底層。
  3. 具備可重複執行的驗收測試:任何未來的改動只要破壞了路徑安全,單元測試會立刻報警。

地基打好、測試通過。
明天在 Day 6 中,我們將在 CodebaseAnalyzer 的基礎上,實作檔案掃描過濾機制與 AST 結構化萃取,正式開始抓出程式碼裡的 Symbol!



上一篇
Day 4:先選範例 repo,再決定怎樣算成功:設計 15 題驗收清單
下一篇
Day 6:掃 repository 前先學會閉眼:排除規則與過濾實作
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言