1.3 仓库与工程骨架:monorepo + TS 包,CI 跑通 lint/test
1.2 给了我们一张四层架构图。但从图到代码之间,还差一件事:仓库长什么样。ResearchCopilot 是双栈的——后端是 Python,前端是 TypeScript(第 9 章要写最小聊天 UI)。如果把两者塞进两个独立仓库,共享的事件协议就得靠手工同步,改一处忘一处。所以本卷用 monorepo:一个仓库,两个语言包,一套 CI。
本节把 ResearchCopilot 推进到「有仓库、有 CI 门」:给出 monorepo 目录树与各目录职责,写出
pyproject.toml与package.json的关键片段,在本机真跑一遍pytest/node --test/tsc --noEmit,并给出 GitHub Actions 的 CI 形态。
1.3.1 为什么是 monorepo
monorepo 不是「把两个项目放一个文件夹」这么简单,它换来的是一组具体收益:
| 维度 | 双仓库 | monorepo |
|---|---|---|
| 事件协议 | 手工同步,易漂移 | 一份 schema,两栈共享 |
| 原子提交 | 前后端分两次提交 | 一次提交同时改两端 |
| CI | 两套流水线 | 一套,可只跑受影响的包 |
| 本地开发 | 起两个仓库、两份环境 | 一条命令起全部 |
代价也有:仓库变大、CI 变长。但对「一个产品、两个语言」这种形态,收益明显压过代价。本卷第 9 章要在 TS 侧复用 Python 侧定义的事件协议,如果分仓库,这件事会非常别扭。
1.3.2 目录树与职责
仓库的骨架如下(真实建出来、跑过的最小版本):
researchcopilot/
├── pyproject.toml # Python 包与服务依赖、pytest 配置
├── packages/
│ ├── researchcopilot/ # 后端 Python 包
│ │ └── researchcopilot/
│ │ ├── __init__.py # 版本号
│ │ └── config.py # Settings(连接串等)
│ └── web/ # 前端 TS 包
│ ├── package.json # node --test / tsc 脚本
│ ├── tsconfig.json # erasableSyntaxOnly
│ └── src/
│ ├── events.ts # 事件协议解析
│ └── events.test.ts # node --test 用例
├── tests/ # Python 测试
│ └── test_smoke.py # 冒烟:版本与默认配置
└── deploy/ # 部署编排(第 16 章填)
四个目录,职责互不重叠:
| 目录 | 职责 | 关键约束 |
|---|---|---|
packages/researchcopilot/ | 后端服务:配置、API、worker、存储访问 | 只依赖 minagent 引擎,不改引擎 |
packages/web/ | 前端:事件协议、聊天 UI | 只用可擦除 TS 语法(Node 26 直接跑) |
tests/ | Python 侧的单元与集成测试 | 走假模型,零密钥 |
deploy/ | Dockerfile、compose、CI 部署描述 | 第 16 章前可以留空占位 |
注意 packages/researchcopilot/researchcopilot/ 这个「双层同名」结构——外层是包的根(放 pyproject.toml),内层是真正的 import 包。这是 Python 打包的惯例(src-less layout),pythonpath 指到外层目录即可。
1.3.3 pyproject.toml:依赖与测试配置
Python 侧的关键片段。版本号全部照本机实测口径写死,不用浮动范围——Agent 工程的依赖漂移代价太高,宁可显式钉版本:
[project]
name = "researchcopilot"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"pydantic==2.14.0",
"pydantic-settings==2.15.0",
"fastapi==0.143.0",
"uvicorn==0.54.0",
"psycopg[binary]==3.3.6",
"redis==8.1.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["packages/researchcopilot"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
两个字段值得说明。pythonpath 让 pytest 无需安装包就能 import researchcopilot——本地开发时不必先 pip install -e .,省一步。testpaths 把收集范围钉死在 tests/,避免 pytest 去扫 packages/web/node_modules 里成千上万的文件。
1.3.4 package.json 与 tsconfig:可擦除语法
TS 侧的 package.json 只声明两个脚本,测试用 Node 内置的 node --test,类型检查用 tsc --noEmit:
{
"name": "@researchcopilot/web",
"private": true,
"type": "module",
"scripts": {
"test": "node --test",
"typecheck": "tsc --noEmit"
},
"devDependencies": { "typescript": "7.0.2" }
}
tsconfig.json 里最关键的一项是 erasableSyntaxOnly:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
为什么要开 erasableSyntaxOnly?因为 Node 26 只支持「类型擦除」(type stripping),不支持 --experimental-transform-types。这意味着所有 TS 示例必须只用可擦除语法:不用参数属性(constructor parameter properties)、不用 enum、不用 namespace。开了这个开关,tsc 会提前把这类不可擦除的写法报错,避免你写出「类型检查能过、但 node file.ts 跑不起来」的代码。
1.3.5 真跑:Python 侧的收集与执行
骨架建好后,第一件要验证的事是「CI 能不能跑起来」。先看 Python 侧。一个最小冒烟测试 tests/test_smoke.py:
from researchcopilot import __version__
from researchcopilot.config import load
def test_version_is_set() -> None:
assert __version__ == "0.1.0"
def test_defaults_point_at_local_containers() -> None:
s = load()
assert s.database_url.startswith("postgresql://")
assert "15435" in s.database_url
assert "16380" in s.redis_url
assert s.max_steps == 8
它测两件事:包的版本号能被读到,以及默认配置指向本机的书稿专用容器(Postgres 15435、Redis 16380——见 BRIEF 的连接串口径)。用 venv 里的 Python 3.14.6 先跑收集:
python -m pytest --collect-only -q
真实输出:
tests/test_smoke.py::test_version_is_set
tests/test_smoke.py::test_defaults_point_at_local_containers
2 tests collected in 0.01s
收集到 2 个用例,说明 pythonpath 配置生效、包能被 import。再真跑一遍:
python -m pytest -q
真实输出:
.. [100%]
2 passed in 0.01s
两个点代表两个用例都通过。注意它没有连任何数据库——冒烟测试只验配置的字符串形态,不验连通性。真连库的集成测试留到第 5 章,那才是 psycopg 出场的地方。
1.3.6 真跑:TS 侧的测试与类型检查
前端包同样要能在 CI 里跑。src/events.ts 解析一条 SSE 帧:
export type AgentEvent =
| { type: "token"; text: string }
| { type: "tool_call"; name: string }
| { type: "final"; content: string };
export function parseSseFrame(frame: string): AgentEvent | null {
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (!line) return null;
return JSON.parse(line.slice(5).trim()) as AgentEvent;
}
src/events.test.ts 用 node:test 写两个用例:
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseSseFrame } from "./events.ts";
test("解析 token 帧", () => {
const ev = parseSseFrame('event: token\ndata: {"type":"token","text":"你好"}');
assert.deepEqual(ev, { type: "token", text: "你好" });
});
test("空帧返回 null", () => {
assert.equal(parseSseFrame("event: ping\n"), null);
});
注意 import 写的是 "./events.ts"——带 .ts 扩展名,因为 Node 的类型擦除模式要求显式扩展名(配合 tsconfig 的 allowImportingTsExtensions)。真跑 node --test(Node 26.5.0,直接执行 .ts,零构建):
node --test
真实输出:
✔ 解析 token 帧 (0.900792ms)
✔ 空帧返回 null (0.1085ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 122.441541
两个用例全绿。再跑类型检查:
npx tsc --noEmit
真实结果:退出码 0,无任何输出(--noEmit 表示只检查不产物;tsc 7.0.2 在全部通过时静默退出)。这里有个实测踩坑值得记下来:第一次跑 tsc 报了两条 TS2591,提示找不到 node:test / node:assert/strict——原因是 tsconfig 里缺 "types": ["node"],且需要装 @types/node(本机实测 26.6.5)。补上这两处后,退出码才变 0。类型检查器报「找不到模块」时,先怀疑 types 与 @types,别急着改代码。
1.3.7 GitHub Actions:一套 CI 跑两个包
本地跑通后,把它固化成 CI。下面这份 YAML 给出形态——注意:本节未在本机执行过这份 workflow,它需要推到 GitHub 后由 Actions 运行,这里只保证结构正确、步骤与上面真跑的命令一一对应:
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- run: python -m pip install --index-url https://mirrors.ustc.edu.cn/pypi/simple -e ".[dev]"
- run: python -m pytest -q
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "26"
- run: npm ci
working-directory: packages/web
- run: npm test
working-directory: packages/web
- run: npm run typecheck
working-directory: packages/web
这份 workflow 有两个 job:python 跑 pytest,web 跑 node --test 与 tsc --noEmit。它们并行,互不阻塞。三条与本地实测的对应关系值得点出:
- Python job 的 pip 源用的是中科大源,这是本机实测下来唯一可靠的源(PyPI 官方源与清华源在本机 pip 下都不可靠,阿里云源会因 pydantic wheel 元数据 404 失败)。
- Node 版本锁
26,与本地一致;npm ci保证依赖树可复现。 webjob 的两个run都带working-directory,因为package.json在子目录。
第 17 章会把这份 CI 扩展成完整的 CI/CD(加构建、加镜像、加灰度),第 12 章会在 python job 里插入一道质量门:评测分数下降就拦截合并。本节只把最基础的「lint/test 跑通」这道门立起来。
1.3.8 真实环境下的 CI 会怎样
这份骨架到了真实项目里,会往三个方向长:
- 加缓存。
pip与npm都该缓存依赖目录,否则每次 CI 都从头下载,几分钟起步。Actions 的cache步骤是标配。 - 按影响范围跑。monorepo 的常见优化:只改了
packages/web/就跳过 Python job。这需要一个「变更检测」步骤,第 17 章讲流水线时展开。 - 加 lint。本节标题里写了 lint,但最小骨架只跑了 test 与 typecheck。真实的 lint(
ruff/eslint)是紧接着要加的,它们和 test 一样,是「合并前必须绿」的门。
本节的自检清单:
-
pytest --collect-only能收集到用例(真跑过) -
pytest通过(真跑过,2 passed) -
node --test通过(真跑过,2 pass) -
tsc --noEmit退出码 0(真跑过) - CI YAML 的步骤与本地命令一一对应(注明未在本机执行)
到这里,第 1 章就完整了:需求(1.1)、架构(1.2)、仓库与 CI(1.3)。骨架有了,下一章往里填第一块真实的砖——分层配置。想回看架构边界,翻 1.2 架构总览 。
阅读导航:上一节:1.2 架构总览:引擎 / 服务 / worker / 存储边界 · 下一节:2.1 分层配置:pydantic-settings 加载与校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。