diff --git a/.gitignore b/.gitignore
index 375328715..dadd1eba9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,4 +77,8 @@ lime-claw.png
lime.db
.codex-*
-target-codex*
\ No newline at end of file
+target-codex*
+
+tmp/
+
+governance/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 666b7c917..8018b4525 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -57,6 +57,7 @@
| [workspace.md](docs/aiprompts/workspace.md) | Workspace 设计文档 |
| [content-creator.md](docs/aiprompts/content-creator.md) | 内容创作系统 |
| [aster-integration.md](docs/aiprompts/aster-integration.md) | Aster 集成方案 |
+| [project-heatmap.md](docs/aiprompts/project-heatmap.md) | 项目热力图与治理图再生成指南 |
| [playwright-e2e.md](docs/aiprompts/playwright-e2e.md) | Playwright MCP 续测与 E2E 指南 |
## 构建命令
@@ -103,6 +104,10 @@ npm run lint
- 需要继续浏览器 E2E、复用现有 Playwright MCP 会话、排查 DevBridge/console 错误时,先读 `docs/aiprompts/playwright-e2e.md`
- 如果只是仓库级规则,不要继续往本文件堆叠步骤说明
+## 项目热力图入口
+
+- 需要重新生成仓库观察热力图、治理候选图、打开现有 HTML 报告或向后续 AI 交接这套流程时,先读 `docs/aiprompts/project-heatmap.md`
+
## UI 设计入口
- 需要统一配色、修正渐变、调整页面宽度策略、重排卡片工作台时,先读 `docs/aiprompts/design-language.md`
@@ -114,7 +119,7 @@ npm run lint
- 后端:Rust + Tauri
- 数据库:SQLite (rusqlite)
-### 核心模块
+### prrovide模块
1. **Provider 系统** (`src-tauri/src/providers/`)
- Kiro/CodeWhisperer OAuth 认证
@@ -137,13 +142,6 @@ npm run lint
- OpenAI ↔ CodeWhisperer 转换
- OpenAI ↔ Claude 转换
-### 凭证管理策略(方案 B)
-
-Kiro 凭证采用完全独立的副本策略:
-- 上传凭证时,自动合并 `clientIdHash` 文件中的 `client_id`/`client_secret` 到副本
-- 每个副本文件完全独立,支持多账号场景
-- 刷新 Token 时只使用副本文件中的凭证,不依赖原始文件
-
## 开发指南
### 添加新 Provider
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 563442050..ebfebc2d8 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,42 +1,36 @@
-## Lime v0.94.0
+## Lime v0.95.0
### ✨ 主要更新
-- **微信 Claw 接入打通并进入可用形态**:基于微信 Gateway 补齐扫码登录、等待登录、账号持久化、默认模型同步、账号清理与运行状态查询能力,设置页可直接生成本地二维码、等待扫码结果并在成功后自动启动网关
-- **Team Workbench 与图片工作台合流**:输入区新增 `@配图` 内建命令,主工作台补齐图片生成、编辑、变体的画布视图、输出流转和继续追问链路,围绕团队协作场景进一步统一消息区与右侧工作区
-- **高风险模型进入“稳妥模式”**:新增 Team/Provider 两层运行时并发治理,对 Zhipu/GLM 一类高风险模型改用顺序排队并显式展示等待提示,优先降低并发下直接失败的概率
-- **渠道日志与运行态诊断增强**:渠道设置页新增 Tail 视图、过滤预设、正则筛选、复制与清空操作,内存日志与持久化日志会合并去重,便于排查渠道联调问题
-- **图片模型路由与资源流转继续收口**:增强图片 Provider 识别、模型筛选与兼容性判断,补齐资源保存、输出复用和工作台跟随式操作,减少图片任务在多 Provider 场景下的手工切换成本
+- **Agent 线程可靠性面板上线**:会话线程新增 pending request、active incident、最近 outcome 与排队 turn 的统一读模型,前端可直接展示“卡在哪里、为什么卡、下一步怎么恢复”
+- **恢复动作从诊断面板直达执行**:支持在线中断当前 turn、恢复线程、重放待处理请求、提升排队 turn,减少遇到等待确认、工具失败或队列阻塞时的手工排障成本
+- **Agent 工作区继续收口**:`AgentChatWorkspace` 大体量逻辑拆分到共享 contract、命令解析/执行层与多个面板组件,消息列表、决策面板、提及面板、队列面板与工作台布局的职责更清晰
+- **Codex slash command 基础接入**:新增 `/compact`、`/clear`、`/new`、`/review`、`/init`、`/diff`、`/status`、`/model`、`/help` 等命令目录与格式化/执行链路,为后续命令扩展提供统一入口
+- **项目热力图与治理图再生成能力补齐**:新增仓库热力图脚本、治理图谱脚本和操作文档,可直接生成热点模块与治理候选报告,方便后续收口 legacy/compat 路径
### ⚠️ 兼容性说明
-- Zhipu/GLM 相关模型在高峰时会按顺序进入执行窗口,属于稳定性优先的行为调整,团队任务的并发体感会与此前不同
-- 微信渠道启用后依赖扫码登录结果、远端网关地址与 CDN 地址保持一致;迁移旧配置时建议重新核对默认账号和默认模型绑定
-- 渠道日志面板现在同时读取内存日志与持久化日志;若手动清空日志,会同步删除当前诊断数据且不可恢复
+- Agent 线程可靠性依赖新的本地数据库表 `agent_turn_outcomes` 与 `agent_thread_incidents`;升级后首次打开相关会话会自动补齐表结构
+- 线程恢复与 pending request 诊断依赖新的 Aster 线程读模型;如果本地还在用 `setup:local-aster` 覆盖,请确认联调仓库已同步到 `aster-rust v0.21.0`
+- release workflow 仍由 `v*` tag 触发,`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文;只推 `main` 不会自动出包
### 🔗 依赖同步
-- 渠道设置页新增 `qrcode` / `@types/qrcode` 用于微信扫码登录展示
-- `lime-gateway` 补充 `aes`、`ecb`、`base64`、`dirs`、`hex`、`md5`、`rand`、`rusqlite` 等依赖,为微信渠道与网关运行时能力提供支持
-- 应用版本同步提升到 `v0.94.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口
+- `aster-core` / `aster-models` 远程 tag 固定到 `v0.21.0`
+- 应用版本同步提升到 `v0.95.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace/package 入口
### 🧪 测试
-- 发布前执行:`cd src-tauri && cargo test`
-- 发布前执行:`cd src-tauri && cargo fmt --all`
-- 发布前执行:`cd src-tauri && cargo clippy`
+- 发布前执行:`cargo fmt --all --manifest-path src-tauri/Cargo.toml`
+- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml`
+- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`npm run lint`
### 📝 文档
+- 补充 `docs/aiprompts/project-heatmap.md`,明确仓库热力图、治理图与 legacy-report 的配套使用方式
- 发布说明随 `RELEASE_NOTES.md` 更新,供 GitHub Release 工作流直接读取
-### 📦 Windows 下载说明
-
-- `Lime_*_x64-offline-setup.exe`:推荐优先使用,内置 WebView2 离线安装器,安装更完整
-- `Lime_*_x64-online-setup.exe`:体积更小,适合网络稳定且可访问微软下载源的环境
-- 如果在线安装失败,请改用离线安装包
-
---
-**完整变更**: v0.93.0...v0.94.0
+**完整变更**: v0.94.0...v0.95.0
diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md
index 6739ae892..3a6997377 100644
--- a/docs/aiprompts/governance.md
+++ b/docs/aiprompts/governance.md
@@ -186,6 +186,23 @@ npm run test:contracts
> 聊天能力后续统一收敛到 `useAgentChatUnified -> useAsterAgentChat -> agent_runtime_* + lime_core::database::agent_session_repository`。
+## 文件级治理图谱配套规则
+
+如果要维护文件 / 页面级治理图谱,仓库内默认配套:
+
+- 人工治理规则:`governance/surfaces.yml`
+- 交互图谱命令:`npm run governance:graph`
+- 既有边界扫描:`npm run governance:legacy-report`
+
+约束:
+
+- `current / compat / deprecated / dead` 只能来自人工治理规则或仓库内既有治理规则
+- 自动脚本只能补 `dead-candidate`、`unused-file`、`unused-export`、`zero-inbound`、`page-unreachable` 等信号
+- `dead` 不能由脚本自动判定,必须人工确认
+- `compat` 规则必须写清 `sourceOfTruth`
+- `deprecated` 规则必须写清 `exitCriteria`
+- 如遇构建期 alias、运行时桥接、特殊入口等静态分析盲区,可补 `ignoreSignals` 仅压制误报 signal,不改变事实源判断
+
### 第三步:优先做减法
默认优先做这些动作,而不是再加一层抽象:
diff --git a/docs/aiprompts/project-heatmap.md b/docs/aiprompts/project-heatmap.md
new file mode 100644
index 000000000..d58134475
--- /dev/null
+++ b/docs/aiprompts/project-heatmap.md
@@ -0,0 +1,335 @@
+# 项目热力图与治理图再生成指南
+
+## 目的
+
+本文件用于指导后续 AI Agent 或人工维护者,稳定地重新生成 Lime 仓库的:
+
+- **项目热力图**:看“哪里大、哪里热、什么时候热”
+- **治理图**:看“哪些模块最值得优先做收口治理”
+
+这里的“治理图”不是独立脚本,而是 `project-heatmap.mjs` 输出 HTML 报告中的 **`治理候选`** 板块。
+
+## 适用场景
+
+当用户出现以下意图时,优先使用本流程:
+
+- “重新生成项目热力图”
+- “看一下现在仓库哪些地方最热”
+- “看一下哪些模块最该治理”
+- “重新做治理图 / 治理候选榜”
+- “帮我打开上次那份热力图报告”
+
+如果用户不是要看仓库演化,而是要看 **legacy / compat / deprecated / dead** 的真实边界,请同时阅读:
+
+- `docs/aiprompts/governance.md`
+
+热力图负责 **发现热点和治理优先级**,治理报告负责 **确认边界分类和封老路状态**。
+
+## 相关文件
+
+- 脚本:`scripts/project-heatmap.mjs`
+- 文件 / 页面治理图谱:`scripts/governance-graph.mjs`
+- 命令入口:`npm run heatmap:project`
+- 命令入口(带连线治理图谱):`npm run governance:graph`
+- 治理规则:`docs/aiprompts/governance.md`
+- 治理扫描:`npm run governance:legacy-report`
+
+## 输出物说明
+
+每次生成都会产出两个文件:
+
+- `index.html`:本地静态可视化报告
+- `project-heatmap.json`:聚合后的结构化数据
+
+报告中主要看三块:
+
+1. **治理候选**:综合体量、churn、密度、分散度、持续活跃度后的治理优先级
+2. **模块体量 + 热度**:Treemap,面积代表 `LOC`,颜色代表 `churn density`
+3. **时间 × 模块热力矩阵**:看某模块是不是持续发热
+
+治理图谱 2.0 另外输出:
+
+- `governance-graph.html`:文件 / 页面级交互图谱(带连线、状态、signals、legacy overlay)
+- `governance-graph.json`:治理图谱结构化数据
+
+默认命令:
+
+```bash
+npm run governance:graph -- --output "./tmp/project-heatmap-governance"
+```
+
+补充说明:
+
+- 图谱状态来源只认仓库内治理规则与既有治理护栏
+- `dead-candidate`、`unused-file`、`zero-inbound` 只是疑似失效信号,不等于正式 `dead`
+- 首期粒度是页面 / 文件,不包含函数调用图
+
+## 标准操作流程
+
+### 1. 先选输出目录
+
+为了方便后续 AI、用户和不同平台复用,**优先显式传 `--output`**,不要依赖系统临时目录默认值。
+
+推荐输出到仓库内相对目录:
+
+```bash
+npm run heatmap:project -- --output "./tmp/project-heatmap"
+```
+
+推荐原因:
+
+- 路径稳定,方便后续 AI 继续打开
+- 不依赖 macOS / Windows 的系统临时目录差异
+- 更适合在对话里直接引用具体文件路径
+
+### 2. 生成“项目热力图”
+
+这是默认的仓库观察视角,适合先总览:
+
+```bash
+npm run heatmap:project -- --days 180 --depth 2 --top 18 --output "./tmp/project-heatmap"
+```
+
+含义:
+
+- `--days 180`:观察最近 180 天的 Git churn
+- `--depth 2`:按目录深度 2 聚合,适合总览 `src` / `src-tauri` / `docs`
+- `--top 18`:矩阵中展示前 18 个热点模块
+
+### 3. 生成“治理图”
+
+如果目标是看 **该治理谁**,推荐使用更细一层的聚合深度:
+
+```bash
+npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance"
+```
+
+推荐参数解释:
+
+- `--days 30`:更适合看近期治理优先级,而不是长期历史噪音
+- `--depth 3`:能把 `src/components/agent`、`src-tauri/src/commands` 这种真实模块层级打出来
+- `--top 15`:矩阵和候选榜更聚焦
+
+### 4. 配套生成治理扫描结果
+
+只看热力图还不够。要确认哪些路径已经被收口、哪些还是 compat / deprecated,还要跑:
+
+```bash
+npm run governance:legacy-report
+```
+
+用途:
+
+- 确认 legacy / compat / deprecated / dead 边界
+- 验证旧入口是否被重新引用
+- 判断是不是已经封住老路
+
+### 5. 打开报告
+
+#### macOS
+
+```bash
+open "./tmp/project-heatmap-governance/index.html"
+```
+
+#### Windows PowerShell
+
+```powershell
+Start-Process ".\\tmp\\project-heatmap-governance\\index.html"
+```
+
+#### 通用降级方式
+
+如果当前 AI 环境不能直接打开 GUI:
+
+- 返回 HTML 文件路径
+- 返回 `project-heatmap.json` 路径
+- 告诉用户“可直接在文件管理器中双击打开 `index.html`”
+
+补充说明:
+
+- 如果 AI 运行在受限沙箱或审批模式下,`open` / `Start-Process` 这类 GUI 打开动作可能需要用户批准
+- 如果无法直接打开,不要卡住流程;优先把可点击文件路径返回给用户
+
+## 推荐命令模板
+
+### 只做总览
+
+```bash
+npm run heatmap:project -- --days 180 --depth 2 --top 18 --output "./tmp/project-heatmap"
+```
+
+### 只看治理优先级
+
+```bash
+npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance"
+npm run governance:legacy-report
+```
+
+### 同时保留两份报告
+
+```bash
+npm run heatmap:project -- --days 180 --depth 2 --top 18 --output "./tmp/project-heatmap"
+npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance"
+```
+
+## AI 执行清单
+
+当后续 AI 被要求“重新生成热力图/治理图”时,建议严格按下面顺序执行:
+
+### A. 先读规则
+
+至少先读:
+
+- `docs/aiprompts/project-heatmap.md`
+- `docs/aiprompts/governance.md`
+
+### B. 再生成
+
+如果用户没指定参数,优先生成两份:
+
+1. 总览热力图
+2. 深度 3 的治理图
+
+推荐命令:
+
+```bash
+npm run heatmap:project -- --days 180 --depth 2 --top 18 --output "./tmp/project-heatmap"
+npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance"
+```
+
+### C. 再补治理扫描
+
+```bash
+npm run governance:legacy-report
+```
+
+### D. 最后再总结
+
+汇报时至少给出:
+
+1. 哪份 HTML 是总览热力图
+2. 哪份 HTML 是治理图
+3. 哪些模块属于 **立即治理**
+4. 哪些模块属于 **尽快治理**
+5. heatmap 发现的热点,与 `governance:legacy-report` 的边界扫描是否一致
+
+## 如何解释结果
+
+### 1. 热力图不等于分类结果
+
+`治理候选` 中的:
+
+- `立即治理`
+- `尽快治理`
+- `持续观察`
+
+是 **优先级启发式判断**,不是 `current / compat / deprecated / dead` 的正式分类。
+
+正式分类必须结合:
+
+- `docs/aiprompts/governance.md`
+- `npm run governance:legacy-report`
+
+### 2. 一个模块“很热”,不一定说明它是坏的
+
+需要优先治理,通常要同时满足几个条件:
+
+- 体量大
+- churn 高
+- 单位体量改动密
+- 文件分散
+- 连续多周活跃
+
+### 3. 一个模块“很旧”,不一定值得现在下刀
+
+如果 `governance:legacy-report` 显示:
+
+- 已零引用
+- 已删除
+- 已受控 compat
+
+那它不是第一优先级。
+优先级更高的通常是 **还在高速演进、还没收口的 current 主链路**。
+
+## 建议默认解读方式
+
+### 总览热力图重点看
+
+- `src/components`
+- `src-tauri/src`
+- `src-tauri/crates`
+- `src/lib`
+- `src/features`
+
+### 治理图重点看
+
+深度 3 结果通常更有用,优先关注例如:
+
+- `src/components/agent`
+- `src/components/workspace`
+- `src/components/settings-v2`
+- `src-tauri/src/commands`
+- `src-tauri/src/dev_bridge`
+- `src/lib/api`
+- `src-tauri/src/services`
+
+## 建议的 AI 结论模板
+
+生成完成后,建议按下面格式汇报:
+
+```text
+已生成两份报告:
+
+- 总览热力图:./tmp/project-heatmap/index.html
+- 治理图:./tmp/project-heatmap-governance/index.html
+
+本轮最值得优先治理的模块:
+- src/components/agent
+- src/components/workspace
+- src-tauri/src/commands
+
+补充验证:
+- governance:legacy-report 已运行
+
+注意:
+- 治理候选是优先级判断,不等于 compat / deprecated 正式分类
+```
+
+## 常见问题
+
+### 1. 为什么看不到治理候选?
+
+可能原因:
+
+- 你打开的是旧报告
+- 输出目录复用了旧文件
+- 使用了过浅的 `--depth`
+
+建议:
+
+```bash
+npm run heatmap:project -- --days 30 --depth 3 --top 15 --output "./tmp/project-heatmap-governance"
+```
+
+### 2. 为什么路径和上次不一样?
+
+因为如果不显式传 `--output`,脚本会默认输出到系统临时目录。
+为了让 AI 会话之间稳定复用,建议始终传:
+
+```bash
+--output "./tmp/project-heatmap-governance"
+```
+
+### 3. 为什么热力图和治理扫描结论不完全一样?
+
+这是正常的:
+
+- 热力图:看“哪里热、哪里值得先下刀”
+- 治理扫描:看“旧路有没有被封住,边界有没有违规”
+
+两者是互补关系,不是重复关系。
+
+## 一句话
+
+**重新生成热力图时,默认出两份:深度 2 看全局,深度 3 看治理;再配合 `npm run governance:legacy-report` 做正式边界判断。**
diff --git a/knip.governance.json b/knip.governance.json
new file mode 100644
index 000000000..eb904950c
--- /dev/null
+++ b/knip.governance.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://unpkg.com/knip@latest/schema.json",
+ "entry": ["src/main.tsx"],
+ "project": [
+ "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
+ "!src/**/*.test.{ts,tsx,js,jsx,mjs,cjs}",
+ "!src/**/*.spec.{ts,tsx,js,jsx,mjs,cjs}",
+ "!src/**/__tests__/**",
+ "!src/**/__mocks__/**",
+ "!src/**/*.d.ts"
+ ]
+}
diff --git a/package.json b/package.json
index a8838a913..223f75b97 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
- "version": "0.94.0",
+ "version": "0.95.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
@@ -12,6 +12,8 @@
},
"homepage": "https://github.com/aiclientproxy/lime",
"scripts": {
+ "heatmap:project": "node scripts/project-heatmap.mjs",
+ "governance:graph": "node scripts/governance-graph.mjs",
"predev": "npm run verify:app-version && node scripts/ensure-dev-port.mjs",
"dev": "vite",
"build": "npm run verify:app-version && tsc && vite build",
@@ -156,6 +158,8 @@
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
+ "cytoscape": "^3.33.1",
+ "dependency-cruiser": "^16.10.4",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
@@ -163,6 +167,7 @@
"globals": "^15.12.0",
"husky": "^9.1.7",
"jsdom": "^22.1.0",
+ "knip": "^6.0.2",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.14",
@@ -170,6 +175,7 @@
"typescript": "^5.6.3",
"vite": "^5.4.21",
"vite-plugin-svgr": "^4.5.0",
- "vitest": "^3.2.4"
+ "vitest": "^3.2.4",
+ "yaml": "^2.8.3"
}
}
diff --git a/scripts/README.md b/scripts/README.md
index d80022886..cda2be5b2 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -57,3 +57,33 @@ When upstream adds new Chinese text:
- **Total entries:** 3,568
- **Translated:** 3,568 (100%)
- **Coverage:** 100%
+
+## 项目热力图
+
+可以使用下面的脚本为当前仓库生成静态项目观察报告:
+
+```bash
+npm run heatmap:project
+```
+
+常用参数:
+
+```bash
+npm run heatmap:project -- --days 90
+npm run heatmap:project -- --output "./tmp/project-heatmap"
+```
+
+报告会输出:
+
+- `index.html`:本地可直接打开的热力图报告
+- `project-heatmap.json`:可复用的聚合数据
+
+默认行为:
+
+- 分析最近 `180` 天的 Git churn
+- 以目录深度 `2` 聚合模块
+- 忽略 `node_modules`、`dist`、`target`、锁文件等噪音输入
+
+完整的 AI 操作流程、治理图生成参数、跨平台打开方式,请读:
+
+- `docs/aiprompts/project-heatmap.md`
diff --git a/scripts/governance-graph.mjs b/scripts/governance-graph.mjs
new file mode 100644
index 000000000..3bf621cf5
--- /dev/null
+++ b/scripts/governance-graph.mjs
@@ -0,0 +1,2418 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { cruise } from "dependency-cruiser";
+import extractTSConfig from "dependency-cruiser/config-utl/extract-ts-config";
+import YAML from "yaml";
+import { buildLegacySurfaceReport } from "./report-legacy-surfaces.mjs";
+import {
+ buildRustModuleIndex,
+ buildRustModulePathFromFile,
+ createDirNodeId,
+ expandRustUseTree,
+ isFrontendCodePath,
+ isPagePath,
+ isRustCodePath,
+ isTestLikePath,
+ normalizePath,
+ resolveMatchingGovernanceRule,
+ resolveRustUseToFile,
+ validateGovernanceRules,
+} from "./lib/governance-graph-core.mjs";
+
+const DEFAULT_SINCE_DAYS = 30;
+const DEFAULT_OUTPUT_DIR = "./tmp/project-heatmap-governance";
+const DEFAULT_RULES_PATH = "./governance/surfaces.yml";
+const DEFAULT_KNIP_CONFIG = "./knip.governance.json";
+
+const IGNORED_DIRECTORIES = new Set([
+ ".git",
+ ".next",
+ ".nuxt",
+ ".output",
+ ".turbo",
+ "coverage",
+ "dist",
+ "node_modules",
+ "out",
+ "target",
+ "target-codex-verify",
+ "tmp",
+ "vendor",
+ "modified_files",
+]);
+
+const FRONTEND_ENTRYPOINTS = ["src/main.tsx", "src/RootRouter.tsx"];
+const STATUS_PRIORITY = new Map([
+ ["dead", 4],
+ ["deprecated", 3],
+ ["compat", 2],
+ ["current", 1],
+ ["unclassified", 0],
+]);
+
+const options = parseArgs(process.argv.slice(2));
+
+if (options.help) {
+ printHelp();
+ process.exit(0);
+}
+
+const repoRoot = resolveRepoRoot(path.resolve(options.root || process.cwd()));
+const outputDir = path.resolve(repoRoot, options.output || DEFAULT_OUTPUT_DIR);
+const rulesPath = path.resolve(repoRoot, options.rules || DEFAULT_RULES_PATH);
+const knipConfigPath = path.resolve(
+ repoRoot,
+ options.knipConfig || DEFAULT_KNIP_CONFIG,
+);
+
+const report = await buildGovernanceGraphReport({
+ repoRoot,
+ outputDir,
+ sinceDays: options.days,
+ rulesPath,
+ knipConfigPath,
+});
+
+fs.mkdirSync(outputDir, { recursive: true });
+
+const jsonOutputPath = path.join(outputDir, "governance-graph.json");
+const htmlOutputPath = path.join(outputDir, "governance-graph.html");
+
+fs.writeFileSync(jsonOutputPath, JSON.stringify(report, null, 2), "utf8");
+fs.writeFileSync(htmlOutputPath, renderHtml(report), "utf8");
+
+console.log("[governance-graph] 治理图谱已生成");
+console.log(`[governance-graph] HTML: ${htmlOutputPath}`);
+console.log(`[governance-graph] JSON: ${jsonOutputPath}`);
+if (report.links.heatmapHtmlHref) {
+ console.log(
+ `[governance-graph] 同目录热力图: ${path.join(outputDir, "index.html")}`,
+ );
+}
+
+function parseArgs(argv) {
+ const result = {
+ root: "",
+ output: "",
+ rules: "",
+ knipConfig: "",
+ days: DEFAULT_SINCE_DAYS,
+ help: false,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+
+ if ((arg === "-h" || arg === "--help") && !result.help) {
+ result.help = true;
+ continue;
+ }
+
+ if (arg === "--root" && argv[index + 1]) {
+ result.root = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--output" && argv[index + 1]) {
+ result.output = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--rules" && argv[index + 1]) {
+ result.rules = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--knip-config" && argv[index + 1]) {
+ result.knipConfig = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--days" && argv[index + 1]) {
+ result.days = normalizePositiveNumber(argv[index + 1], DEFAULT_SINCE_DAYS);
+ index += 1;
+ }
+ }
+
+ return result;
+}
+
+function normalizePositiveNumber(value, fallback) {
+ const parsed = Number.parseInt(String(value), 10);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ return fallback;
+ }
+ return parsed;
+}
+
+function printHelp() {
+ console.log(`
+Lime 治理图谱生成器
+
+用法:
+ npm run governance:graph
+ npm run governance:graph -- --output "./tmp/project-heatmap-governance"
+ npm run governance:graph -- --days 60 --rules "./governance/surfaces.yml"
+
+选项:
+ --root PATH 指定仓库根目录,默认当前目录
+ --output PATH 指定输出目录,默认 ${DEFAULT_OUTPUT_DIR}
+ --rules PATH 指定治理规则 YAML,默认 ${DEFAULT_RULES_PATH}
+ --knip-config PATH 指定 knip 配置,默认 ${DEFAULT_KNIP_CONFIG}
+ --days N 最近 N 天 churn,默认 ${DEFAULT_SINCE_DAYS}
+ -h, --help 显示帮助
+`);
+}
+
+async function buildGovernanceGraphReport({
+ repoRoot,
+ outputDir,
+ sinceDays,
+ rulesPath,
+ knipConfigPath,
+}) {
+ const fileInventory = collectGovernanceFiles(repoRoot);
+ const fileIndex = new Map(
+ fileInventory.map((fileRecord) => [fileRecord.path, fileRecord]),
+ );
+ const gitCommand = process.platform === "win32" ? "git.exe" : "git";
+ const fileChurn = collectGitChurn({
+ gitCommand,
+ repoRoot,
+ sinceDays,
+ trackedFiles: fileIndex,
+ });
+ const frontendGraph = await collectFrontendDependencies(repoRoot, fileIndex);
+ const rustGraph = collectRustDependencies(repoRoot, fileInventory, fileIndex);
+ const governanceRules = loadGovernanceRules(rulesPath);
+ const knipSignals = collectKnipSignals(repoRoot, knipConfigPath);
+ const legacyReport = buildLegacySurfaceReport();
+ const legacyOverlays = buildLegacyOverlays(legacyReport);
+ const frontendReachable = collectReachableFiles(
+ FRONTEND_ENTRYPOINTS.filter((entryPath) => fileIndex.has(entryPath)),
+ frontendGraph.edges,
+ );
+ const baseNodes = buildFileNodes({
+ repoRoot,
+ fileInventory,
+ fileChurn,
+ governanceRules,
+ knipSignals,
+ legacyOverlays,
+ reachableFrontendPaths: frontendReachable,
+ });
+ const edges = dedupeEdges([...frontendGraph.edges, ...rustGraph.edges]);
+ const nodes = finalizeNodeSignals(baseNodes, edges, governanceRules);
+ const dirNodes = buildDirectoryNodes(nodes);
+ const allNodes = [...dirNodes, ...nodes];
+ const signalsSummary = summarizeSignals(nodes);
+ const summary = buildSummary(nodes, edges, signalsSummary);
+ const links = {
+ selfHtmlHref: pathToFileURL(path.join(outputDir, "governance-graph.html")).href,
+ selfJsonHref: pathToFileURL(path.join(outputDir, "governance-graph.json")).href,
+ heatmapHtmlHref: fs.existsSync(path.join(outputDir, "index.html"))
+ ? pathToFileURL(path.join(outputDir, "index.html")).href
+ : "",
+ };
+
+ return {
+ generatedAt: new Date().toISOString(),
+ repoRoot: normalizePath(repoRoot),
+ rulesPath: normalizePath(path.relative(repoRoot, rulesPath)),
+ knipConfigPath: normalizePath(path.relative(repoRoot, knipConfigPath)),
+ summary,
+ signalsSummary,
+ nodes: allNodes,
+ edges,
+ links,
+ };
+}
+
+function resolveRepoRoot(targetPath) {
+ const gitCommand = process.platform === "win32" ? "git.exe" : "git";
+
+ try {
+ const output = execFileSync(
+ gitCommand,
+ ["-C", targetPath, "rev-parse", "--show-toplevel"],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ },
+ ).trim();
+
+ if (output) {
+ return path.resolve(output);
+ }
+ } catch {
+ return targetPath;
+ }
+
+ return targetPath;
+}
+
+function collectGovernanceFiles(repoRoot) {
+ const records = [];
+ const roots = ["src", "src-tauri/src"];
+
+ for (const root of roots) {
+ const absoluteRoot = path.join(repoRoot, root);
+ if (!fs.existsSync(absoluteRoot)) {
+ continue;
+ }
+
+ walkDirectory(absoluteRoot, repoRoot, (absolutePath) => {
+ const relativePath = normalizePath(path.relative(repoRoot, absolutePath));
+ const isCodePath =
+ isFrontendCodePath(relativePath) || isRustCodePath(relativePath);
+ if (!isCodePath || isTestLikePath(relativePath)) {
+ return;
+ }
+
+ const ext = path.extname(relativePath).toLowerCase();
+ const language = ext === ".rs" ? "rs" : ext.replace(/^\./, "");
+ const layer = relativePath.startsWith("src-tauri/src/") ? "rust" : "frontend";
+
+ records.push({
+ path: relativePath,
+ kind: isPagePath(relativePath) ? "page" : "file",
+ layer,
+ language,
+ loc: countLinesSafely(absolutePath),
+ });
+ });
+ }
+
+ return records.sort((left, right) => left.path.localeCompare(right.path));
+}
+
+function walkDirectory(currentDir, repoRoot, onFile) {
+ const dirEntries = fs
+ .readdirSync(currentDir, { withFileTypes: true })
+ .sort((left, right) => left.name.localeCompare(right.name));
+
+ for (const entry of dirEntries) {
+ const absolutePath = path.join(currentDir, entry.name);
+ const relativePath = normalizePath(path.relative(repoRoot, absolutePath));
+
+ if (entry.isDirectory()) {
+ if (shouldIgnoreDirectory(entry.name, relativePath)) {
+ continue;
+ }
+ walkDirectory(absolutePath, repoRoot, onFile);
+ continue;
+ }
+
+ if (entry.isFile()) {
+ onFile(absolutePath);
+ }
+ }
+}
+
+function shouldIgnoreDirectory(entryName, relativePath) {
+ if (IGNORED_DIRECTORIES.has(entryName)) {
+ return true;
+ }
+
+ return relativePath
+ .split("/")
+ .filter(Boolean)
+ .some((segment) => IGNORED_DIRECTORIES.has(segment));
+}
+
+function countLinesSafely(filePath) {
+ try {
+ const content = fs.readFileSync(filePath, "utf8");
+ if (!content) {
+ return 0;
+ }
+ return content.split(/\r?\n/).length;
+ } catch {
+ return 0;
+ }
+}
+
+function collectGitChurn({ gitCommand, repoRoot, sinceDays, trackedFiles }) {
+ const fileChurn = new Map();
+
+ let output = "";
+
+ try {
+ output = execFileSync(
+ gitCommand,
+ [
+ "-C",
+ repoRoot,
+ "log",
+ `--since=${sinceDays}.days`,
+ "--numstat",
+ "--date=short",
+ "--format=format:@@@%cs",
+ ],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ },
+ );
+ } catch {
+ return fileChurn;
+ }
+
+ let currentDate = "";
+
+ for (const line of output.split(/\r?\n/)) {
+ if (!line) {
+ continue;
+ }
+
+ if (line.startsWith("@@@")) {
+ currentDate = line.slice(3).trim();
+ continue;
+ }
+
+ const parts = line.split("\t");
+ if (parts.length !== 3 || !currentDate) {
+ continue;
+ }
+
+ const [added, deleted, rawPath] = parts;
+ if (added === "-" || deleted === "-") {
+ continue;
+ }
+
+ const normalizedGitPath = normalizeGitPath(rawPath);
+ if (!trackedFiles.has(normalizedGitPath)) {
+ continue;
+ }
+
+ const churn = Number.parseInt(added, 10) + Number.parseInt(deleted, 10);
+ if (!Number.isFinite(churn) || churn <= 0) {
+ continue;
+ }
+
+ fileChurn.set(normalizedGitPath, (fileChurn.get(normalizedGitPath) || 0) + churn);
+ }
+
+ return fileChurn;
+}
+
+function normalizeGitPath(rawPath) {
+ let normalized = rawPath.trim().replaceAll("\\", "/");
+
+ if (!normalized.includes("=>")) {
+ return normalized;
+ }
+
+ normalized = normalized.replace(
+ /\{([^{}]+)\s=>\s([^{}]+)\}/g,
+ (_match, _before, after) => after,
+ );
+
+ if (normalized.includes("=>")) {
+ const parts = normalized.split("=>");
+ normalized = parts[parts.length - 1].trim();
+ }
+
+ return normalized.replaceAll("//", "/");
+}
+
+async function collectFrontendDependencies(repoRoot, fileIndex) {
+ const cruiseResult = await cruise(
+ ["src"],
+ {
+ includeOnly: "^src",
+ exclude: "(^|/)(node_modules|dist|coverage)(/|$)",
+ },
+ undefined,
+ {
+ tsConfig: extractTSConfig(path.join(repoRoot, "tsconfig.json")),
+ },
+ );
+ const edges = [];
+
+ for (const moduleRecord of cruiseResult.output.modules ?? []) {
+ const sourcePath = normalizePath(moduleRecord.source || "");
+ if (!fileIndex.has(sourcePath)) {
+ continue;
+ }
+
+ for (const dependency of moduleRecord.dependencies ?? []) {
+ const targetPath = normalizePath(dependency.resolved || "");
+ if (
+ !targetPath ||
+ dependency.couldNotResolve ||
+ !fileIndex.has(targetPath) ||
+ targetPath === sourcePath
+ ) {
+ continue;
+ }
+
+ edges.push({
+ id: `import:${sourcePath}->${targetPath}`,
+ source: sourcePath,
+ target: targetPath,
+ kind: "import",
+ });
+ }
+ }
+
+ return { edges };
+}
+
+function collectRustDependencies(repoRoot, fileInventory, fileIndex) {
+ const rustFiles = fileInventory.filter((fileRecord) => fileRecord.layer === "rust");
+ const rustFileSet = new Set(rustFiles.map((fileRecord) => fileRecord.path));
+ const moduleIndex = buildRustModuleIndex([...rustFileSet]);
+ const edges = [];
+
+ for (const fileRecord of rustFiles) {
+ const absolutePath = path.join(repoRoot, fileRecord.path);
+ const sourceCode = fs.readFileSync(absolutePath, "utf8");
+ const currentModulePath = buildRustModulePathFromFile(fileRecord.path) || "";
+
+ for (const moduleName of extractRustModDeclarations(sourceCode)) {
+ const targetPath = resolveRustSubmodulePath(
+ repoRoot,
+ fileRecord.path,
+ moduleName,
+ );
+ if (!targetPath || !fileIndex.has(targetPath)) {
+ continue;
+ }
+ edges.push({
+ id: `rust_mod:${fileRecord.path}->${targetPath}`,
+ source: fileRecord.path,
+ target: targetPath,
+ kind: "rust_mod",
+ });
+ }
+
+ for (const statement of extractRustUseStatements(sourceCode)) {
+ for (const usePath of expandRustUseTree(statement)) {
+ const targetPath = resolveRustUseToFile(
+ moduleIndex,
+ currentModulePath,
+ usePath,
+ );
+
+ if (!targetPath || targetPath === fileRecord.path) {
+ continue;
+ }
+
+ edges.push({
+ id: `rust_use:${fileRecord.path}->${targetPath}:${usePath}`,
+ source: fileRecord.path,
+ target: targetPath,
+ kind: "rust_use",
+ });
+ }
+ }
+ }
+
+ return { edges };
+}
+
+function extractRustModDeclarations(sourceCode) {
+ const matches = sourceCode.matchAll(
+ /(?:^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g,
+ );
+ return [...matches].map((match) => match[1]);
+}
+
+function extractRustUseStatements(sourceCode) {
+ const matches = sourceCode.matchAll(
+ /(?:^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([\s\S]*?);/g,
+ );
+ return [...matches].map((match) => `use ${String(match[1]).trim()};`);
+}
+
+function resolveRustSubmodulePath(repoRoot, relativePath, moduleName) {
+ const normalizedPath = normalizePath(relativePath);
+ const fileName = path.posix.basename(normalizedPath);
+ const dirName = path.posix.dirname(normalizedPath);
+ const moduleFolder =
+ fileName === "mod.rs" || fileName === "main.rs" || fileName === "lib.rs"
+ ? dirName
+ : path.posix.join(dirName, fileName.replace(/\.rs$/u, ""));
+
+ const directCandidate = path.posix.join(moduleFolder, `${moduleName}.rs`);
+ if (fs.existsSync(path.join(repoRoot, directCandidate))) {
+ return normalizePath(directCandidate);
+ }
+
+ const nestedCandidate = path.posix.join(moduleFolder, moduleName, "mod.rs");
+ if (fs.existsSync(path.join(repoRoot, nestedCandidate))) {
+ return normalizePath(nestedCandidate);
+ }
+
+ return null;
+}
+
+function loadGovernanceRules(rulesPath) {
+ if (!fs.existsSync(rulesPath)) {
+ return [];
+ }
+
+ const document = YAML.parse(fs.readFileSync(rulesPath, "utf8")) || {};
+ const rules = document.rules || [];
+ validateGovernanceRules(rules);
+ return rules;
+}
+
+function collectKnipSignals(repoRoot, knipConfigPath) {
+ if (!fs.existsSync(knipConfigPath)) {
+ return {
+ unusedFiles: new Set(),
+ unusedExportsByFile: new Map(),
+ };
+ }
+
+ const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx";
+ const output = execFileSync(
+ npxCommand,
+ [
+ "--no-install",
+ "knip",
+ "--config",
+ knipConfigPath,
+ "--reporter",
+ "json",
+ "--include",
+ "files,exports",
+ "--no-exit-code",
+ "--directory",
+ repoRoot,
+ ],
+ {
+ cwd: repoRoot,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ },
+ );
+
+ const parsed = JSON.parse(output);
+ const unusedFiles = new Set();
+ const unusedExportsByFile = new Map();
+
+ for (const issue of parsed.issues ?? []) {
+ for (const fileIssue of issue.files ?? []) {
+ const filePath = normalizePath(fileIssue.name);
+ if (isFrontendCodePath(filePath)) {
+ unusedFiles.add(filePath);
+ }
+ }
+
+ if ((issue.exports ?? []).length > 0) {
+ const filePath = normalizePath(issue.file);
+ if (!isFrontendCodePath(filePath)) {
+ continue;
+ }
+
+ const exportNames = (unusedExportsByFile.get(filePath) ?? []).concat(
+ issue.exports.map((item) => item.name),
+ );
+ unusedExportsByFile.set(filePath, exportNames);
+ }
+ }
+
+ return {
+ unusedFiles,
+ unusedExportsByFile,
+ };
+}
+
+function buildLegacyOverlays(legacyReport) {
+ const surfaceOverlays = new Map();
+ const callsiteOverlays = new Map();
+
+ for (const result of legacyReport.importResults) {
+ for (const targetPath of result.existingTargets) {
+ pushOverlay(surfaceOverlays, targetPath, {
+ source: "legacy-report",
+ overlayType: "surface",
+ monitorId: result.id,
+ classification: result.classification,
+ description: result.description,
+ statusHint: mapLegacyClassificationToStatus(result.classification),
+ });
+ }
+ }
+
+ for (const result of legacyReport.commandResults) {
+ for (const references of result.referencesByCommand.values()) {
+ for (const callerPath of references) {
+ pushOverlay(callsiteOverlays, callerPath, {
+ source: "legacy-report",
+ overlayType: "callsite",
+ monitorId: result.id,
+ classification: result.classification,
+ description: result.description,
+ });
+ }
+ }
+ }
+
+ for (const result of [...legacyReport.frontendTextResults, ...legacyReport.rustTextResults]) {
+ for (const callerPath of result.references) {
+ pushOverlay(callsiteOverlays, callerPath, {
+ source: "legacy-report",
+ overlayType: "callsite",
+ monitorId: result.id,
+ classification: result.classification,
+ description: result.description,
+ });
+ }
+ }
+
+ for (const result of legacyReport.rustTextCountResults) {
+ for (const callerPath of result.runtimeMatches.map((item) => item.relativePath)) {
+ pushOverlay(callsiteOverlays, callerPath, {
+ source: "legacy-report",
+ overlayType: "callsite",
+ monitorId: result.id,
+ classification: result.classification,
+ description: result.description,
+ });
+ }
+ }
+
+ return {
+ surfaceOverlays,
+ callsiteOverlays,
+ };
+}
+
+function pushOverlay(bucket, targetPath, overlay) {
+ const normalizedPath = normalizePath(targetPath);
+ const existing = bucket.get(normalizedPath) ?? [];
+ existing.push(overlay);
+ bucket.set(normalizedPath, existing);
+}
+
+function mapLegacyClassificationToStatus(classification) {
+ if (classification === "compat" || classification === "deprecated") {
+ return classification;
+ }
+ return null;
+}
+
+function buildFileNodes({
+ repoRoot,
+ fileInventory,
+ fileChurn,
+ governanceRules,
+ knipSignals,
+ legacyOverlays,
+ reachableFrontendPaths,
+}) {
+ const nodes = [];
+
+ for (const fileRecord of fileInventory) {
+ const matchingRule = resolveMatchingGovernanceRule(
+ fileRecord.path,
+ governanceRules,
+ );
+ const surfaceOverlays =
+ legacyOverlays.surfaceOverlays.get(fileRecord.path) ?? [];
+ const callsiteOverlays =
+ legacyOverlays.callsiteOverlays.get(fileRecord.path) ?? [];
+ const explicitStatus = matchingRule?.status ?? "unclassified";
+ const overlayStatus = pickOverlayStatus(surfaceOverlays);
+ const status = explicitStatus !== "unclassified" ? explicitStatus : overlayStatus;
+ const ruleSource = matchingRule
+ ? {
+ reason: matchingRule.reason || "",
+ sourceOfTruth: matchingRule.sourceOfTruth || "",
+ exitCriteria: matchingRule.exitCriteria || "",
+ ignoreSignals: matchingRule.ignoreSignals || [],
+ }
+ : null;
+ const overlayReason = surfaceOverlays[0]?.description || "";
+ const signals = new Set();
+
+ if (knipSignals.unusedFiles.has(fileRecord.path)) {
+ signals.add("unused-file");
+ }
+
+ if ((knipSignals.unusedExportsByFile.get(fileRecord.path) ?? []).length > 0) {
+ signals.add("unused-export");
+ }
+
+ if (
+ surfaceOverlays.some((overlay) => overlay.classification === "dead-candidate")
+ ) {
+ signals.add("dead-candidate");
+ }
+
+ if (callsiteOverlays.length > 0) {
+ signals.add("legacy-callsite");
+ }
+
+ const node = {
+ id: fileRecord.path,
+ label: path.posix.basename(fileRecord.path),
+ path: fileRecord.path,
+ kind: fileRecord.kind,
+ layer: fileRecord.layer,
+ language: fileRecord.language,
+ status,
+ loc: fileRecord.loc,
+ churn: fileChurn.get(fileRecord.path) || 0,
+ size: calculateNodeSize(fileRecord.loc),
+ href: pathToFileURL(path.join(repoRoot, fileRecord.path)).href,
+ parent: createParentDirectoryId(fileRecord.path),
+ reason: ruleSource?.reason || overlayReason,
+ sourceOfTruth: ruleSource?.sourceOfTruth || "",
+ exitCriteria: ruleSource?.exitCriteria || "",
+ ignoredSignals: ruleSource?.ignoreSignals || [],
+ overlays: [...surfaceOverlays, ...callsiteOverlays],
+ signals: [...signals].sort(),
+ reachable:
+ fileRecord.layer === "frontend"
+ ? reachableFrontendPaths.has(fileRecord.path)
+ : true,
+ };
+
+ nodes.push(node);
+ }
+
+ return nodes;
+}
+
+function finalizeNodeSignals(nodes, edges, governanceRules) {
+ const fileNodesByPath = new Map(nodes.map((node) => [node.path, node]));
+ const indegree = new Map();
+
+ for (const edge of edges) {
+ indegree.set(edge.target, (indegree.get(edge.target) || 0) + 1);
+ }
+
+ const inboundIndex = buildInboundIndex(edges);
+
+ return nodes.map((node) => {
+ const signals = new Set(node.signals);
+
+ if (
+ !node.reachable &&
+ node.kind === "page" &&
+ node.layer === "frontend"
+ ) {
+ signals.add("page-unreachable");
+ }
+
+ if (
+ (indegree.get(node.path) || 0) === 0 &&
+ !isProtectedEntryNode(node, governanceRules)
+ ) {
+ signals.add("zero-inbound");
+ }
+
+ const inbound = inboundIndex.get(node.path) ?? [];
+ if (
+ inbound.length > 0 &&
+ inbound.every((sourcePath) => {
+ const sourceNode = fileNodesByPath.get(sourcePath);
+ return (
+ sourceNode &&
+ ["compat", "deprecated", "dead"].includes(sourceNode.status)
+ );
+ })
+ ) {
+ signals.add("legacy-only-incoming");
+ }
+
+ for (const ignoredSignal of node.ignoredSignals || []) {
+ signals.delete(ignoredSignal);
+ }
+
+ return {
+ ...node,
+ signals: [...signals].sort(),
+ candidateScore: calculateCandidateScore({
+ ...node,
+ signals: [...signals],
+ }),
+ };
+ });
+}
+
+function pickOverlayStatus(surfaceOverlays) {
+ const candidates = surfaceOverlays
+ .map((overlay) => overlay.statusHint || "unclassified")
+ .filter((status) => status !== "unclassified");
+
+ if (candidates.length === 0) {
+ return "unclassified";
+ }
+
+ return candidates.sort(
+ (left, right) =>
+ (STATUS_PRIORITY.get(right) || 0) - (STATUS_PRIORITY.get(left) || 0),
+ )[0];
+}
+
+function calculateNodeSize(loc) {
+ return Math.max(26, Math.min(86, Math.round(18 + Math.sqrt(Math.max(loc, 1)))));
+}
+
+function createParentDirectoryId(relativePath) {
+ const dirPath = path.posix.dirname(relativePath);
+ if (!dirPath || dirPath === ".") {
+ return "";
+ }
+ return createDirNodeId(dirPath);
+}
+
+function buildDirectoryNodes(fileNodes) {
+ const dirMap = new Map();
+
+ for (const node of fileNodes) {
+ const dirPath = path.posix.dirname(node.path);
+ if (!dirPath || dirPath === ".") {
+ continue;
+ }
+
+ const segments = dirPath.split("/").filter(Boolean);
+ for (let length = 1; length <= segments.length; length += 1) {
+ const currentPath = segments.slice(0, length).join("/");
+ if (dirMap.has(currentPath)) {
+ continue;
+ }
+ dirMap.set(currentPath, {
+ id: createDirNodeId(currentPath),
+ label: segments[length - 1],
+ path: currentPath,
+ kind: "dir",
+ layer: currentPath.startsWith("src-tauri/") ? "rust" : "frontend",
+ parent:
+ length > 1
+ ? createDirNodeId(segments.slice(0, length - 1).join("/"))
+ : "",
+ });
+ }
+ }
+
+ return [...dirMap.values()].sort((left, right) => left.path.localeCompare(right.path));
+}
+
+function collectReachableFiles(entryPaths, edges) {
+ const adjacency = new Map();
+ const queue = [...entryPaths];
+ const visited = new Set(entryPaths);
+
+ for (const edge of edges) {
+ const currentTargets = adjacency.get(edge.source) ?? [];
+ currentTargets.push(edge.target);
+ adjacency.set(edge.source, currentTargets);
+ }
+
+ while (queue.length > 0) {
+ const currentPath = queue.shift();
+ for (const nextPath of adjacency.get(currentPath) ?? []) {
+ if (visited.has(nextPath)) {
+ continue;
+ }
+ visited.add(nextPath);
+ queue.push(nextPath);
+ }
+ }
+
+ return visited;
+}
+
+function dedupeEdges(edges) {
+ const edgeMap = new Map();
+ for (const edge of edges) {
+ const key = `${edge.kind}:${edge.source}->${edge.target}`;
+ if (!edgeMap.has(key)) {
+ edgeMap.set(key, {
+ ...edge,
+ id: key,
+ });
+ }
+ }
+ return [...edgeMap.values()];
+}
+
+function buildInboundIndex(edges) {
+ const inboundIndex = new Map();
+ for (const edge of edges) {
+ const entries = inboundIndex.get(edge.target) ?? [];
+ entries.push(edge.source);
+ inboundIndex.set(edge.target, entries);
+ }
+ return inboundIndex;
+}
+
+function isProtectedEntryNode(node, governanceRules) {
+ if (node.status === "current") {
+ return true;
+ }
+
+ if (FRONTEND_ENTRYPOINTS.includes(node.path)) {
+ return true;
+ }
+
+ if (/(?:^|\/)(main|lib)\.rs$/u.test(node.path)) {
+ return true;
+ }
+
+ return resolveMatchingGovernanceRule(node.path, governanceRules)?.status === "current";
+}
+
+function calculateCandidateScore(node) {
+ let score = 0;
+
+ for (const signal of node.signals) {
+ if (signal === "unused-file") {
+ score += 5;
+ } else if (signal === "page-unreachable") {
+ score += 5;
+ } else if (signal === "dead-candidate") {
+ score += 4;
+ } else if (signal === "zero-inbound") {
+ score += 2;
+ } else if (signal === "legacy-only-incoming") {
+ score += 2;
+ } else if (signal === "unused-export") {
+ score += 1;
+ } else if (signal === "legacy-callsite") {
+ score += 1;
+ }
+ }
+
+ return score;
+}
+
+function summarizeSignals(nodes) {
+ const counts = new Map();
+ for (const node of nodes) {
+ for (const signal of node.signals) {
+ counts.set(signal, (counts.get(signal) || 0) + 1);
+ }
+ }
+
+ return [...counts.entries()]
+ .map(([signal, count]) => ({ signal, count }))
+ .sort((left, right) => right.count - left.count || left.signal.localeCompare(right.signal));
+}
+
+function buildSummary(nodes, edges, signalsSummary) {
+ const fileNodes = nodes.filter((node) => node.kind !== "dir");
+ const statusCounts = Object.fromEntries(
+ ["current", "compat", "deprecated", "dead", "unclassified"].map((status) => [
+ status,
+ fileNodes.filter((node) => node.status === status).length,
+ ]),
+ );
+ const layerCounts = Object.fromEntries(
+ ["frontend", "rust"].map((layer) => [
+ layer,
+ fileNodes.filter((node) => node.layer === layer).length,
+ ]),
+ );
+ const topCandidates = fileNodes
+ .filter((node) => node.candidateScore > 0)
+ .sort(
+ (left, right) =>
+ right.candidateScore - left.candidateScore ||
+ right.churn - left.churn ||
+ left.path.localeCompare(right.path),
+ )
+ .slice(0, 20)
+ .map((node) => ({
+ path: node.path,
+ status: node.status,
+ candidateScore: node.candidateScore,
+ signals: node.signals,
+ churn: node.churn,
+ }));
+
+ return {
+ nodeCount: fileNodes.length,
+ edgeCount: edges.length,
+ pageCount: fileNodes.filter((node) => node.kind === "page").length,
+ statusCounts,
+ layerCounts,
+ topCandidates,
+ signalsSummary,
+ };
+}
+
+function renderHtml(report) {
+ const payload = serializeForHtml(report);
+
+ return `
+
+
+
+
+ Lime 治理图谱
+
+
+
+
+
+
+
Lime 治理图谱
+
+ 文件 / 页面级依赖图 + 治理标签 + 疑似失效信号。状态只来自
+ governance/surfaces.yml 与现有治理规则;自动脚本只补 signals 与 overlay。
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+function serializeForHtml(value) {
+ return JSON.stringify(value)
+ .replaceAll("<", "\\u003c")
+ .replaceAll(">", "\\u003e")
+ .replaceAll("&", "\\u0026");
+}
+
+function escapeAttribute(value) {
+ return String(value).replaceAll('"', """);
+}
diff --git a/scripts/lib/governance-graph-core.mjs b/scripts/lib/governance-graph-core.mjs
new file mode 100644
index 000000000..e0b1b6e44
--- /dev/null
+++ b/scripts/lib/governance-graph-core.mjs
@@ -0,0 +1,340 @@
+import path from "node:path";
+
+export const GOVERNANCE_STATUSES = new Set([
+ "current",
+ "compat",
+ "deprecated",
+ "dead",
+ "unclassified",
+]);
+
+export function normalizePath(filePath) {
+ return filePath.split(path.sep).join("/");
+}
+
+export function isFrontendCodePath(relativePath) {
+ return (
+ relativePath.startsWith("src/") &&
+ /\.(?:[cm]?[jt]sx?)$/i.test(relativePath) &&
+ !relativePath.endsWith(".d.ts")
+ );
+}
+
+export function isRustCodePath(relativePath) {
+ return relativePath.startsWith("src-tauri/src/") && relativePath.endsWith(".rs");
+}
+
+export function isTestLikePath(relativePath) {
+ return (
+ /(^|\/)tests(\/|$)/.test(relativePath) ||
+ /(^|\/)(__tests__|__mocks__)(\/|$)/.test(relativePath) ||
+ /\.(test|spec)\.[^/.]+$/.test(relativePath) ||
+ /(^|\/)tests\.rs$/.test(relativePath)
+ );
+}
+
+export function isPagePath(relativePath) {
+ return (
+ /^src\/pages\/.+\.(?:[jt]sx?)$/i.test(relativePath) &&
+ !/^src\/pages\/index\.(?:[jt]sx?)$/i.test(relativePath)
+ );
+}
+
+export function createDirNodeId(relativePath) {
+ return `dir:${relativePath}`;
+}
+
+export function validateGovernanceRules(rules) {
+ if (!Array.isArray(rules)) {
+ throw new Error("治理规则文件必须提供 rules 数组。");
+ }
+
+ for (const [index, rule] of rules.entries()) {
+ if (!rule || typeof rule !== "object") {
+ throw new Error(`治理规则第 ${index + 1} 项必须是对象。`);
+ }
+
+ if (!rule.match || typeof rule.match !== "string") {
+ throw new Error(`治理规则第 ${index + 1} 项缺少 match。`);
+ }
+
+ if (!GOVERNANCE_STATUSES.has(rule.status)) {
+ throw new Error(
+ `治理规则 ${rule.match} 的 status 无效:${String(rule.status)}`,
+ );
+ }
+
+ if (
+ rule.ignoreSignals != null &&
+ (!Array.isArray(rule.ignoreSignals) ||
+ rule.ignoreSignals.some((item) => typeof item !== "string"))
+ ) {
+ throw new Error(`治理规则 ${rule.match} 的 ignoreSignals 必须是字符串数组。`);
+ }
+
+ if (rule.status === "compat" && !rule.sourceOfTruth) {
+ throw new Error(`compat 规则 ${rule.match} 必须声明 sourceOfTruth。`);
+ }
+
+ if (rule.status === "deprecated" && !rule.exitCriteria) {
+ throw new Error(`deprecated 规则 ${rule.match} 必须声明 exitCriteria。`);
+ }
+ }
+}
+
+export function globToRegExp(globPattern) {
+ let pattern = "^";
+
+ for (let index = 0; index < globPattern.length; index += 1) {
+ const char = globPattern[index];
+ const next = globPattern[index + 1];
+
+ if (char === "*") {
+ if (next === "*") {
+ pattern += ".*";
+ index += 1;
+ } else {
+ pattern += "[^/]*";
+ }
+ continue;
+ }
+
+ if (char === "?") {
+ pattern += "[^/]";
+ continue;
+ }
+
+ if ("\\^$+?.()|{}[]".includes(char)) {
+ pattern += `\\${char}`;
+ continue;
+ }
+
+ pattern += char;
+ }
+
+ pattern += "$";
+ return new RegExp(pattern);
+}
+
+export function getRuleSpecificityScore(rule) {
+ const match = String(rule.match || "");
+ const literalChars = match.replace(/[*?]/g, "").length;
+ const doubleStars = (match.match(/\*\*/g) || []).length;
+ const singleStars = (match.match(/\*/g) || []).length - doubleStars * 2;
+ const questions = (match.match(/\?/g) || []).length;
+ return literalChars * 10 - doubleStars * 6 - singleStars * 3 - questions * 2;
+}
+
+export function resolveMatchingGovernanceRule(relativePath, rules) {
+ const normalizedPath = normalizePath(relativePath);
+ const matches = rules
+ .map((rule, index) => ({
+ ...rule,
+ _index: index,
+ _score: getRuleSpecificityScore(rule),
+ _regexp: globToRegExp(rule.match),
+ }))
+ .filter((rule) => rule._regexp.test(normalizedPath))
+ .sort((left, right) => right._score - left._score || left._index - right._index);
+
+ return matches[0] ?? null;
+}
+
+export function buildRustModulePathFromFile(relativePath) {
+ const normalizedPath = normalizePath(relativePath);
+
+ if (!normalizedPath.startsWith("src-tauri/src/") || !normalizedPath.endsWith(".rs")) {
+ return null;
+ }
+
+ const withoutRoot = normalizedPath.slice("src-tauri/src/".length);
+
+ if (withoutRoot === "main.rs" || withoutRoot === "lib.rs") {
+ return "";
+ }
+
+ if (withoutRoot.endsWith("/mod.rs")) {
+ return withoutRoot.slice(0, -"/mod.rs".length).split("/").join("::");
+ }
+
+ return withoutRoot.slice(0, -".rs".length).split("/").join("::");
+}
+
+export function buildRustModuleIndex(relativePaths) {
+ const moduleIndex = new Map();
+
+ for (const relativePath of relativePaths) {
+ const modulePath = buildRustModulePathFromFile(relativePath);
+ if (modulePath == null) {
+ continue;
+ }
+
+ if (!moduleIndex.has(modulePath)) {
+ moduleIndex.set(modulePath, normalizePath(relativePath));
+ }
+ }
+
+ return moduleIndex;
+}
+
+export function splitTopLevel(value, separator = ",") {
+ const parts = [];
+ let depth = 0;
+ let current = "";
+
+ for (const char of value) {
+ if (char === "{") {
+ depth += 1;
+ current += char;
+ continue;
+ }
+
+ if (char === "}") {
+ depth = Math.max(0, depth - 1);
+ current += char;
+ continue;
+ }
+
+ if (char === separator && depth === 0) {
+ if (current.trim()) {
+ parts.push(current.trim());
+ }
+ current = "";
+ continue;
+ }
+
+ current += char;
+ }
+
+ if (current.trim()) {
+ parts.push(current.trim());
+ }
+
+ return parts;
+}
+
+function joinRustPath(prefix, segment) {
+ if (!prefix) {
+ return segment;
+ }
+ if (!segment) {
+ return prefix;
+ }
+ return `${prefix}::${segment}`;
+}
+
+function stripRustVisibilityPrefix(statement) {
+ return statement
+ .trim()
+ .replace(/^(?:pub(?:\([^)]*\))?\s+)?use\s+/, "")
+ .replace(/;$/, "")
+ .trim();
+}
+
+export function expandRustUseTree(statement) {
+ const root = stripRustVisibilityPrefix(statement);
+
+ if (!root) {
+ return [];
+ }
+
+ return expandRustUseSegment(root);
+}
+
+function expandRustUseSegment(segment, prefix = "") {
+ const trimmed = segment.trim();
+
+ if (!trimmed) {
+ return [];
+ }
+
+ const braceIndex = findTopLevelBraceIndex(trimmed);
+ if (braceIndex === -1) {
+ const normalizedLeaf = trimmed.replace(/\s+as\s+.+$/u, "").trim();
+ if (normalizedLeaf === "self") {
+ return prefix ? [prefix] : [];
+ }
+ return [joinRustPath(prefix, normalizedLeaf)];
+ }
+
+ const prefixPart = trimmed.slice(0, braceIndex).replace(/::$/, "").trim();
+ const inner = trimmed.slice(braceIndex + 1, trimmed.lastIndexOf("}"));
+ const nextPrefix = joinRustPath(prefix, prefixPart);
+
+ return splitTopLevel(inner).flatMap((part) =>
+ expandRustUseSegment(part, nextPrefix),
+ );
+}
+
+function findTopLevelBraceIndex(value) {
+ let depth = 0;
+
+ for (let index = 0; index < value.length; index += 1) {
+ const char = value[index];
+ if (char === "{") {
+ if (depth === 0) {
+ return index;
+ }
+ depth += 1;
+ continue;
+ }
+ if (char === "}") {
+ depth = Math.max(0, depth - 1);
+ }
+ }
+
+ return -1;
+}
+
+export function resolveRustUseCandidateModules(currentModulePath, usePath) {
+ const currentSegments = currentModulePath ? currentModulePath.split("::") : [];
+ let remainder = usePath.trim();
+ let baseSegments = [];
+
+ if (remainder === "crate") {
+ return [];
+ }
+
+ if (remainder.startsWith("crate::")) {
+ remainder = remainder.slice("crate::".length);
+ } else if (remainder === "self") {
+ remainder = "";
+ baseSegments = [...currentSegments];
+ } else if (remainder.startsWith("self::")) {
+ remainder = remainder.slice("self::".length);
+ baseSegments = [...currentSegments];
+ } else if (remainder === "super" || remainder.startsWith("super::")) {
+ baseSegments = [...currentSegments];
+ while (remainder === "super" || remainder.startsWith("super::")) {
+ baseSegments.pop();
+ remainder =
+ remainder === "super" ? "" : remainder.slice("super::".length);
+ }
+ } else {
+ return [];
+ }
+
+ const targetSegments = remainder ? remainder.split("::").filter(Boolean) : [];
+ const absoluteSegments = [...baseSegments, ...targetSegments];
+ const candidates = [];
+
+ for (let length = absoluteSegments.length; length > 0; length -= 1) {
+ candidates.push(absoluteSegments.slice(0, length).join("::"));
+ }
+
+ return candidates;
+}
+
+export function resolveRustUseToFile(moduleIndex, currentModulePath, usePath) {
+ for (const candidate of resolveRustUseCandidateModules(
+ currentModulePath,
+ usePath,
+ )) {
+ const resolvedPath = moduleIndex.get(candidate);
+ if (resolvedPath) {
+ return resolvedPath;
+ }
+ }
+
+ return null;
+}
diff --git a/scripts/lib/governance-graph-core.test.ts b/scripts/lib/governance-graph-core.test.ts
new file mode 100644
index 000000000..397b2b1e7
--- /dev/null
+++ b/scripts/lib/governance-graph-core.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildRustModuleIndex,
+ expandRustUseTree,
+ resolveMatchingGovernanceRule,
+ resolveRustUseToFile,
+} from "./governance-graph-core.mjs";
+
+describe("governance-graph-core", () => {
+ it("应优先命中更具体的治理规则", () => {
+ const rules = [
+ {
+ match: "src/**/*.tsx",
+ status: "deprecated",
+ exitCriteria: "fallback",
+ },
+ {
+ match: "src/pages/smart-input.tsx",
+ status: "current",
+ sourceOfTruth: "RootRouter",
+ },
+ ];
+
+ const matched = resolveMatchingGovernanceRule(
+ "src/pages/smart-input.tsx",
+ rules,
+ );
+
+ expect(matched?.status).toBe("current");
+ expect(matched?.match).toBe("src/pages/smart-input.tsx");
+ });
+
+ it("应展开带嵌套花括号的 Rust use 树", () => {
+ const expanded = expandRustUseTree(
+ "use crate::commands::{agent_cmd::{run, status}, model_cmd, self};",
+ );
+
+ expect(expanded).toEqual([
+ "crate::commands::agent_cmd::run",
+ "crate::commands::agent_cmd::status",
+ "crate::commands::model_cmd",
+ "crate::commands",
+ ]);
+ });
+
+ it("应解析 crate/self/super Rust use 到文件路径", () => {
+ const moduleIndex = buildRustModuleIndex([
+ "src-tauri/src/app/mod.rs",
+ "src-tauri/src/app/bootstrap.rs",
+ "src-tauri/src/commands/mod.rs",
+ "src-tauri/src/commands/agent_cmd.rs",
+ "src-tauri/src/commands/internal/helper.rs",
+ ]);
+
+ expect(
+ resolveRustUseToFile(
+ moduleIndex,
+ "commands::agent_cmd",
+ "crate::app::bootstrap::boot",
+ ),
+ ).toBe("src-tauri/src/app/bootstrap.rs");
+
+ expect(
+ resolveRustUseToFile(
+ moduleIndex,
+ "commands::internal::helper",
+ "super::super::agent_cmd::run_agent",
+ ),
+ ).toBe("src-tauri/src/commands/agent_cmd.rs");
+
+ expect(
+ resolveRustUseToFile(
+ moduleIndex,
+ "commands::internal::helper",
+ "self::helper",
+ ),
+ ).toBe("src-tauri/src/commands/internal/helper.rs");
+ });
+});
diff --git a/scripts/project-heatmap.mjs b/scripts/project-heatmap.mjs
new file mode 100644
index 000000000..d0fc29e11
--- /dev/null
+++ b/scripts/project-heatmap.mjs
@@ -0,0 +1,2114 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { execFileSync } from "node:child_process";
+
+const DEFAULT_SINCE_DAYS = 180;
+const DEFAULT_MODULE_DEPTH = 2;
+const DEFAULT_TOP_MODULES = 18;
+const DEFAULT_OUTPUT_DIR_NAME = "lime-project-heatmap";
+
+const TEXT_FILE_EXTENSIONS = new Set([
+ ".cjs",
+ ".conf",
+ ".css",
+ ".html",
+ ".java",
+ ".js",
+ ".json",
+ ".jsx",
+ ".md",
+ ".mdx",
+ ".mjs",
+ ".mts",
+ ".ps1",
+ ".py",
+ ".rb",
+ ".rs",
+ ".scss",
+ ".sh",
+ ".sql",
+ ".toml",
+ ".ts",
+ ".tsx",
+ ".txt",
+ ".yaml",
+ ".yml",
+]);
+
+const INCLUDED_FILE_NAMES = new Set([
+ "AGENTS.md",
+ "CLAUDE.md",
+ "Dockerfile",
+ "LICENSE",
+ "Makefile",
+ "README.md",
+]);
+
+const IGNORED_DIRECTORIES = new Set([
+ ".git",
+ ".next",
+ ".nuxt",
+ ".output",
+ ".turbo",
+ "coverage",
+ "dist",
+ "node_modules",
+ "out",
+ "target",
+ "target-codex-verify",
+ "tmp",
+ "vendor",
+ "modified_files",
+]);
+
+const IGNORED_FILES = new Set([
+ "package-lock.json",
+ "pnpm-lock.yaml",
+ "yarn.lock",
+ "bun.lockb",
+ "Cargo.lock",
+]);
+
+const COLOR_STOPS = [
+ { at: 0, color: "#ecfdf5" },
+ { at: 0.2, color: "#c7f9cc" },
+ { at: 0.45, color: "#6ee7b7" },
+ { at: 0.7, color: "#fbbf24" },
+ { at: 1, color: "#f97316" },
+];
+
+const options = parseArgs(process.argv.slice(2));
+
+if (options.help) {
+ printHelp();
+ process.exit(0);
+}
+
+const gitCommand = process.platform === "win32" ? "git.exe" : "git";
+const requestedRoot = path.resolve(options.root || process.cwd());
+const repoRoot = resolveRepoRoot(requestedRoot);
+const outputDir = path.resolve(
+ options.output ||
+ path.join(
+ os.tmpdir(),
+ `${path.basename(repoRoot) || DEFAULT_OUTPUT_DIR_NAME}-project-heatmap`,
+ ),
+);
+
+const scanResult = scanProjectFiles(repoRoot, options.moduleDepth);
+const gitChurn = collectGitChurn({
+ gitCommand,
+ repoRoot,
+ sinceDays: options.days,
+ trackedFiles: scanResult.fileIndex,
+});
+const report = buildReport({
+ repoRoot,
+ scanResult,
+ gitChurn,
+ sinceDays: options.days,
+ moduleDepth: options.moduleDepth,
+ topModules: options.top,
+});
+
+fs.mkdirSync(outputDir, { recursive: true });
+
+const jsonOutputPath = path.join(outputDir, "project-heatmap.json");
+const htmlOutputPath = path.join(outputDir, "index.html");
+
+fs.writeFileSync(jsonOutputPath, JSON.stringify(report, null, 2), "utf8");
+fs.writeFileSync(htmlOutputPath, renderHtml(report), "utf8");
+
+console.log(`[heatmap] 报告已生成`);
+console.log(`[heatmap] HTML: ${htmlOutputPath}`);
+console.log(`[heatmap] JSON: ${jsonOutputPath}`);
+console.log(`[heatmap] 打开方式: file://${htmlOutputPath}`);
+
+function parseArgs(argv) {
+ const result = {
+ root: "",
+ output: "",
+ days: DEFAULT_SINCE_DAYS,
+ moduleDepth: DEFAULT_MODULE_DEPTH,
+ top: DEFAULT_TOP_MODULES,
+ help: false,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+
+ if (arg === "--root" && argv[index + 1]) {
+ result.root = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--output" && argv[index + 1]) {
+ result.output = String(argv[index + 1]).trim();
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--days" && argv[index + 1]) {
+ result.days = normalizePositiveNumber(argv[index + 1], DEFAULT_SINCE_DAYS);
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--depth" && argv[index + 1]) {
+ result.moduleDepth = normalizePositiveNumber(
+ argv[index + 1],
+ DEFAULT_MODULE_DEPTH,
+ );
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--top" && argv[index + 1]) {
+ result.top = normalizePositiveNumber(argv[index + 1], DEFAULT_TOP_MODULES);
+ index += 1;
+ continue;
+ }
+
+ if (arg === "--help" || arg === "-h") {
+ result.help = true;
+ }
+ }
+
+ return result;
+}
+
+function normalizePositiveNumber(value, fallback) {
+ const parsed = Number.parseInt(String(value), 10);
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ return fallback;
+ }
+ return parsed;
+}
+
+function printHelp() {
+ console.log(`
+Lime 项目热力图生成器
+
+用法:
+ npm run heatmap:project
+ npm run heatmap:project -- --days 90
+ npm run heatmap:project -- --root "../other-repo" --output "./tmp/heatmap"
+
+选项:
+ --root PATH 指定要分析的仓库路径,默认当前 Git 根目录
+ --output PATH 指定报告输出目录,默认系统临时目录
+ --days N 分析最近 N 天的 Git churn,默认 ${DEFAULT_SINCE_DAYS}
+ --depth N 模块聚合目录深度,默认 ${DEFAULT_MODULE_DEPTH}
+ --top N 矩阵热力图显示前 N 个热点模块,默认 ${DEFAULT_TOP_MODULES}
+ -h, --help 显示帮助
+
+说明:
+ - 默认忽略 node_modules、dist、target 等目录
+ - 默认忽略 package-lock.json、pnpm-lock.yaml、Cargo.lock 等锁文件
+ - HTML 报告为纯本地静态文件,可直接使用浏览器打开
+`);
+}
+
+function resolveRepoRoot(targetPath) {
+ try {
+ const output = execFileSync(gitCommand, ["-C", targetPath, "rev-parse", "--show-toplevel"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ }).trim();
+
+ if (output) {
+ return path.resolve(output);
+ }
+ } catch {
+ return targetPath;
+ }
+
+ return targetPath;
+}
+
+function scanProjectFiles(repoPath, moduleDepth) {
+ const modules = new Map();
+ const files = [];
+ const fileIndex = new Map();
+
+ walkDirectory(repoPath);
+
+ const moduleList = Array.from(modules.values()).sort((left, right) => {
+ if (right.loc !== left.loc) {
+ return right.loc - left.loc;
+ }
+ return left.path.localeCompare(right.path);
+ });
+
+ return {
+ files,
+ fileIndex,
+ modules: moduleList,
+ summary: {
+ fileCount: files.length,
+ loc: files.reduce((total, file) => total + file.loc, 0),
+ },
+ };
+
+ function walkDirectory(currentDir) {
+ const dirEntries = fs
+ .readdirSync(currentDir, { withFileTypes: true })
+ .sort((left, right) => left.name.localeCompare(right.name));
+
+ for (const entry of dirEntries) {
+ const absolutePath = path.join(currentDir, entry.name);
+ const relativePath = toPosixPath(path.relative(repoPath, absolutePath));
+
+ if (entry.isDirectory()) {
+ if (shouldIgnoreDirectory(entry.name, relativePath)) {
+ continue;
+ }
+
+ walkDirectory(absolutePath);
+ continue;
+ }
+
+ if (!entry.isFile()) {
+ continue;
+ }
+
+ if (!shouldIncludeFile(entry.name, relativePath)) {
+ continue;
+ }
+
+ const loc = countLinesSafely(absolutePath);
+ const modulePath = resolveModulePath(relativePath, moduleDepth);
+ const group = resolveGroup(modulePath);
+
+ const fileRecord = {
+ path: relativePath,
+ modulePath,
+ group,
+ loc,
+ };
+
+ files.push(fileRecord);
+ fileIndex.set(relativePath, fileRecord);
+
+ let moduleRecord = modules.get(modulePath);
+ if (!moduleRecord) {
+ moduleRecord = {
+ id: modulePath,
+ path: modulePath,
+ group,
+ loc: 0,
+ fileCount: 0,
+ };
+ modules.set(modulePath, moduleRecord);
+ }
+
+ moduleRecord.loc += loc;
+ moduleRecord.fileCount += 1;
+ }
+ }
+}
+
+function shouldIgnoreDirectory(entryName, relativePath) {
+ if (IGNORED_DIRECTORIES.has(entryName)) {
+ return true;
+ }
+
+ const segments = relativePath.split("/").filter(Boolean);
+ return segments.some((segment) => IGNORED_DIRECTORIES.has(segment));
+}
+
+function shouldIncludeFile(fileName, relativePath) {
+ if (IGNORED_FILES.has(fileName)) {
+ return false;
+ }
+
+ if (relativePath.startsWith("docs/node_modules/")) {
+ return false;
+ }
+
+ const extension = path.extname(fileName).toLowerCase();
+
+ if (TEXT_FILE_EXTENSIONS.has(extension)) {
+ return true;
+ }
+
+ return INCLUDED_FILE_NAMES.has(fileName);
+}
+
+function countLinesSafely(filePath) {
+ try {
+ const content = fs.readFileSync(filePath, "utf8");
+ if (!content) {
+ return 0;
+ }
+ return content.split(/\r?\n/).length;
+ } catch {
+ return 0;
+ }
+}
+
+function resolveModulePath(relativePath, moduleDepth) {
+ const parts = relativePath.split("/").filter(Boolean);
+
+ if (parts.length <= 1) {
+ return "(root)";
+ }
+
+ const safeDepth = Math.max(1, moduleDepth);
+ const moduleParts = parts.slice(0, Math.min(safeDepth, parts.length - 1));
+ return moduleParts.join("/");
+}
+
+function resolveGroup(modulePath) {
+ if (modulePath === "(root)") {
+ return "root";
+ }
+
+ const [firstSegment = "root"] = modulePath.split("/");
+ return firstSegment;
+}
+
+function collectGitChurn({ gitCommand, repoRoot, sinceDays, trackedFiles }) {
+ const fileChurn = new Map();
+ const weekSet = new Set();
+ let gitAvailable = true;
+ let commitCount = 0;
+
+ let output = "";
+
+ try {
+ output = execFileSync(
+ gitCommand,
+ [
+ "-C",
+ repoRoot,
+ "log",
+ `--since=${sinceDays}.days`,
+ "--numstat",
+ "--date=short",
+ "--format=format:@@@%cs",
+ ],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ },
+ );
+ } catch {
+ gitAvailable = false;
+ }
+
+ if (!gitAvailable || !output) {
+ return {
+ gitAvailable,
+ commitCount,
+ fileChurn,
+ weeks: [],
+ };
+ }
+
+ let currentDate = "";
+
+ for (const line of output.split(/\r?\n/)) {
+ if (!line) {
+ continue;
+ }
+
+ if (line.startsWith("@@@")) {
+ currentDate = line.slice(3).trim();
+ commitCount += 1;
+ continue;
+ }
+
+ const parts = line.split("\t");
+ if (parts.length !== 3) {
+ continue;
+ }
+
+ const [added, deleted, rawPath] = parts;
+ if (added === "-" || deleted === "-" || !currentDate) {
+ continue;
+ }
+
+ const normalizedPath = normalizeGitPath(rawPath);
+ const trackedFile = trackedFiles.get(normalizedPath);
+ if (!trackedFile) {
+ continue;
+ }
+
+ const churn = Number.parseInt(added, 10) + Number.parseInt(deleted, 10);
+ if (!Number.isFinite(churn) || churn <= 0) {
+ continue;
+ }
+
+ const weekKey = toWeekKey(currentDate);
+ weekSet.add(weekKey);
+
+ let fileRecord = fileChurn.get(normalizedPath);
+ if (!fileRecord) {
+ fileRecord = {
+ path: normalizedPath,
+ churn: 0,
+ weekly: {},
+ };
+ fileChurn.set(normalizedPath, fileRecord);
+ }
+
+ fileRecord.churn += churn;
+ fileRecord.weekly[weekKey] = (fileRecord.weekly[weekKey] || 0) + churn;
+ }
+
+ return {
+ gitAvailable,
+ commitCount,
+ fileChurn,
+ weeks: Array.from(weekSet).sort(),
+ };
+}
+
+function normalizeGitPath(rawPath) {
+ let normalized = rawPath.trim().replaceAll("\\", "/");
+
+ if (!normalized.includes("=>")) {
+ return normalized;
+ }
+
+ normalized = normalized.replace(
+ /\{([^{}]+)\s=>\s([^{}]+)\}/g,
+ (_, _before, after) => after,
+ );
+
+ if (normalized.includes("=>")) {
+ const segments = normalized.split("=>");
+ normalized = segments[segments.length - 1].trim();
+ }
+
+ return normalized.replaceAll("//", "/");
+}
+
+function toWeekKey(dateString) {
+ const date = new Date(`${dateString}T00:00:00Z`);
+ const day = date.getUTCDay() || 7;
+ date.setUTCDate(date.getUTCDate() + 4 - day);
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
+ const weekNumber = Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
+ return `${date.getUTCFullYear()}-W${String(weekNumber).padStart(2, "0")}`;
+}
+
+function buildReport({
+ repoRoot,
+ scanResult,
+ gitChurn,
+ sinceDays,
+ moduleDepth,
+ topModules,
+}) {
+ const modulesById = new Map();
+
+ for (const moduleRecord of scanResult.modules) {
+ modulesById.set(moduleRecord.path, {
+ ...moduleRecord,
+ churn: 0,
+ churnDensity: 0,
+ weekly: {},
+ files: [],
+ });
+ }
+
+ const enrichedFiles = scanResult.files.map((file) => {
+ const churnRecord = gitChurn.fileChurn.get(file.path);
+ const churn = churnRecord?.churn || 0;
+ const churnDensity = file.loc > 0 ? roundTo(churn / file.loc, 4) : 0;
+ const weekly = churnRecord?.weekly || {};
+
+ const enriched = {
+ ...file,
+ churn,
+ churnDensity,
+ weekly,
+ };
+
+ const moduleRecord = modulesById.get(file.modulePath);
+ if (moduleRecord) {
+ moduleRecord.churn += churn;
+ moduleRecord.files.push({
+ path: file.path,
+ loc: file.loc,
+ churn,
+ churnDensity,
+ });
+
+ for (const [week, value] of Object.entries(weekly)) {
+ moduleRecord.weekly[week] = (moduleRecord.weekly[week] || 0) + value;
+ }
+ }
+
+ return enriched;
+ });
+
+ const modules = Array.from(modulesById.values())
+ .map((moduleRecord) => ({
+ ...moduleRecord,
+ activeWeekCount: Object.keys(moduleRecord.weekly).length,
+ churnDensity:
+ moduleRecord.loc > 0
+ ? roundTo(moduleRecord.churn / moduleRecord.loc, 4)
+ : 0,
+ files: moduleRecord.files
+ .sort((left, right) => {
+ if (right.churn !== left.churn) {
+ return right.churn - left.churn;
+ }
+ return right.loc - left.loc;
+ })
+ .slice(0, 6),
+ }))
+ .sort((left, right) => {
+ if (right.churn !== left.churn) {
+ return right.churn - left.churn;
+ }
+ if (right.loc !== left.loc) {
+ return right.loc - left.loc;
+ }
+ return left.path.localeCompare(right.path);
+ });
+
+ const hotFiles = enrichedFiles
+ .filter((file) => file.churn > 0 || file.loc > 0)
+ .sort((left, right) => {
+ if (right.churn !== left.churn) {
+ return right.churn - left.churn;
+ }
+ if (right.loc !== left.loc) {
+ return right.loc - left.loc;
+ }
+ return left.path.localeCompare(right.path);
+ })
+ .slice(0, 24);
+
+ const lastTouchedByPath = resolveLastTouchedDates(
+ repoRoot,
+ hotFiles.map((file) => file.path),
+ );
+
+ const hotFilesWithDates = hotFiles.map((file) => ({
+ ...file,
+ lastTouchedAt: lastTouchedByPath.get(file.path) || "",
+ }));
+
+ const groups = buildGroups(modules);
+ const weeks = gitChurn.weeks;
+ const governance = buildGovernanceCandidates(
+ modules,
+ Math.max(8, Math.min(12, topModules)),
+ );
+
+ return {
+ meta: {
+ repoName: path.basename(repoRoot),
+ repoRoot: toPosixPath(repoRoot),
+ generatedAt: new Date().toISOString(),
+ sinceDays,
+ moduleDepth,
+ topModules,
+ gitAvailable: gitChurn.gitAvailable,
+ commitCount: gitChurn.commitCount,
+ ignoredDirectories: Array.from(IGNORED_DIRECTORIES).sort(),
+ ignoredFiles: Array.from(IGNORED_FILES).sort(),
+ notes: [
+ "面积代表 LOC,颜色代表最近窗口内的 churn 强度。",
+ "默认只统计当前仍存在的文件;历史已删除文件不会进入报告。",
+ "重命名文件的早期历史可能无法完全归并到当前路径,这是当前 MVP 的已知取舍。",
+ ],
+ },
+ summary: {
+ totalFiles: scanResult.summary.fileCount,
+ totalLoc: scanResult.summary.loc,
+ totalModules: modules.length,
+ totalChurn: modules.reduce((total, moduleRecord) => total + moduleRecord.churn, 0),
+ activeWeeks: weeks.length,
+ },
+ weeks,
+ groups,
+ modules,
+ hotFiles: hotFilesWithDates,
+ governance,
+ };
+}
+
+function buildGovernanceCandidates(modules, topN) {
+ if (modules.length === 0) {
+ return {
+ thresholds: {
+ locHigh: 0,
+ churnHigh: 0,
+ densityHigh: 0,
+ filesHigh: 0,
+ activeWeeksHigh: 0,
+ },
+ candidates: [],
+ };
+ }
+
+ const locValues = modules.map((moduleRecord) => moduleRecord.loc);
+ const churnValues = modules.map((moduleRecord) => moduleRecord.churn);
+ const densityValues = modules.map((moduleRecord) => moduleRecord.churnDensity);
+ const fileCountValues = modules.map((moduleRecord) => moduleRecord.fileCount);
+ const activeWeekValues = modules.map(
+ (moduleRecord) => moduleRecord.activeWeekCount,
+ );
+
+ const thresholds = {
+ locHigh: percentileValue(locValues, 0.85),
+ churnHigh: percentileValue(churnValues, 0.85),
+ densityHigh: percentileValue(densityValues, 0.8),
+ filesHigh: percentileValue(fileCountValues, 0.85),
+ activeWeeksHigh: percentileValue(activeWeekValues, 0.8),
+ };
+
+ const maxLoc = Math.max(...locValues, 1);
+ const maxChurn = Math.max(...churnValues, 1);
+ const maxDensity = Math.max(...densityValues, 0.0001);
+ const maxFiles = Math.max(...fileCountValues, 1);
+ const maxActiveWeeks = Math.max(...activeWeekValues, 1);
+
+ const candidates = modules
+ .map((moduleRecord) => {
+ const sizeScore = Math.log1p(moduleRecord.loc) / Math.log1p(maxLoc);
+ const churnScore =
+ Math.log1p(moduleRecord.churn) / Math.log1p(Math.max(1, maxChurn));
+ const densityScore = moduleRecord.churnDensity / maxDensity;
+ const scatterScore =
+ Math.log1p(moduleRecord.fileCount) / Math.log1p(maxFiles);
+ const persistenceScore =
+ Math.log1p(moduleRecord.activeWeekCount) / Math.log1p(maxActiveWeeks);
+ const governanceScore = roundTo(
+ (sizeScore * 0.32 +
+ churnScore * 0.28 +
+ densityScore * 0.18 +
+ scatterScore * 0.12 +
+ persistenceScore * 0.1) *
+ 100,
+ 1,
+ );
+
+ const reasons = buildGovernanceReasons(moduleRecord, thresholds);
+
+ return {
+ path: moduleRecord.path,
+ group: moduleRecord.group,
+ loc: moduleRecord.loc,
+ churn: moduleRecord.churn,
+ churnDensity: moduleRecord.churnDensity,
+ fileCount: moduleRecord.fileCount,
+ activeWeekCount: moduleRecord.activeWeekCount,
+ governanceScore,
+ severity: resolveGovernanceSeverity(governanceScore, reasons),
+ reasons,
+ suggestion: buildGovernanceSuggestion(reasons),
+ };
+ })
+ .sort((left, right) => {
+ if (right.governanceScore !== left.governanceScore) {
+ return right.governanceScore - left.governanceScore;
+ }
+ if (right.churn !== left.churn) {
+ return right.churn - left.churn;
+ }
+ return right.loc - left.loc;
+ })
+ .slice(0, topN);
+
+ return {
+ thresholds: {
+ ...thresholds,
+ densityHigh: roundTo(thresholds.densityHigh, 4),
+ },
+ candidates,
+ };
+}
+
+function percentileValue(values, percentile) {
+ if (!values || values.length === 0) {
+ return 0;
+ }
+
+ const sorted = [...values].sort((left, right) => left - right);
+ const index = Math.min(
+ sorted.length - 1,
+ Math.max(0, Math.floor((sorted.length - 1) * percentile)),
+ );
+ return sorted[index];
+}
+
+function buildGovernanceReasons(moduleRecord, thresholds) {
+ const reasons = [];
+
+ if (moduleRecord.loc >= thresholds.locHigh) {
+ reasons.push("体量大");
+ }
+
+ if (moduleRecord.churn >= thresholds.churnHigh && moduleRecord.churn > 0) {
+ reasons.push("近期变更频繁");
+ }
+
+ if (
+ moduleRecord.churnDensity >= thresholds.densityHigh &&
+ moduleRecord.churn > 0
+ ) {
+ reasons.push("单位体量改动密");
+ }
+
+ if (moduleRecord.fileCount >= thresholds.filesHigh) {
+ reasons.push("文件分散");
+ }
+
+ if (
+ moduleRecord.activeWeekCount >= thresholds.activeWeeksHigh &&
+ moduleRecord.activeWeekCount > 1
+ ) {
+ reasons.push("持续发热");
+ }
+
+ if (reasons.length === 0) {
+ reasons.push("需要观察");
+ }
+
+ return reasons;
+}
+
+function resolveGovernanceSeverity(governanceScore, reasons) {
+ const hasScaleSignal =
+ reasons.includes("体量大") || reasons.includes("文件分散");
+ const hasHeatSignal =
+ reasons.includes("近期变更频繁") || reasons.includes("单位体量改动密");
+
+ if (governanceScore >= 80 && hasScaleSignal && hasHeatSignal) {
+ return "立即治理";
+ }
+
+ if (governanceScore >= 60 && reasons.length >= 2) {
+ return "尽快治理";
+ }
+
+ return "持续观察";
+}
+
+function buildGovernanceSuggestion(reasons) {
+ if (reasons.includes("体量大") && reasons.includes("文件分散")) {
+ return "先定义唯一事实源,再收敛入口与目录边界。";
+ }
+
+ if (reasons.includes("单位体量改动密") && reasons.includes("持续发热")) {
+ return "先冻结抽象,再补守卫,避免继续长出平级实现。";
+ }
+
+ if (reasons.includes("近期变更频繁")) {
+ return "优先盘点入口层与服务层,找出重复分支后做减法。";
+ }
+
+ return "先保持观测,等下一轮需求前确认是否要收口。";
+}
+
+function buildGroups(modules) {
+ const groupMap = new Map();
+
+ for (const moduleRecord of modules) {
+ let group = groupMap.get(moduleRecord.group);
+ if (!group) {
+ group = {
+ id: moduleRecord.group,
+ label: moduleRecord.group === "root" ? "根目录" : moduleRecord.group,
+ loc: 0,
+ churn: 0,
+ moduleCount: 0,
+ fileCount: 0,
+ };
+ groupMap.set(moduleRecord.group, group);
+ }
+
+ group.loc += moduleRecord.loc;
+ group.churn += moduleRecord.churn;
+ group.moduleCount += 1;
+ group.fileCount += moduleRecord.fileCount;
+ }
+
+ const result = [
+ {
+ id: "all",
+ label: "全部",
+ loc: modules.reduce((total, moduleRecord) => total + moduleRecord.loc, 0),
+ churn: modules.reduce(
+ (total, moduleRecord) => total + moduleRecord.churn,
+ 0,
+ ),
+ moduleCount: modules.length,
+ fileCount: modules.reduce(
+ (total, moduleRecord) => total + moduleRecord.fileCount,
+ 0,
+ ),
+ },
+ ...Array.from(groupMap.values()).sort((left, right) => {
+ if (right.churn !== left.churn) {
+ return right.churn - left.churn;
+ }
+ return right.loc - left.loc;
+ }),
+ ];
+
+ return result;
+}
+
+function resolveLastTouchedDates(repoRoot, filePaths) {
+ const result = new Map();
+
+ if (filePaths.length === 0) {
+ return result;
+ }
+
+ for (const filePath of filePaths) {
+ try {
+ const value = execFileSync(
+ gitCommand,
+ ["-C", repoRoot, "log", "-1", "--date=short", "--format=%cs", "--", filePath],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ },
+ ).trim();
+
+ if (value) {
+ result.set(filePath, value);
+ }
+ } catch {
+ result.set(filePath, "");
+ }
+ }
+
+ return result;
+}
+
+function renderHtml(report) {
+ const embeddedData = JSON.stringify(report).replace(/
+
+
+
+
+ ${escapeHtml(report.meta.repoName)} 项目热力图
+
+
+
+
+
+
+
项目观察热力图 · ${escapeHtml(report.meta.repoName)}
+
用规模、变更和时间热度观察仓库演化
+
+ 这份报告聚合了当前仓库文件规模、最近 ${report.meta.sinceDays}
+ 天 Git churn,以及按周汇总的模块活跃度。
+ 面积优先回答“哪里大”,颜色优先回答“哪里热”,矩阵优先回答“什么时候热”。
+
+
+
+
+
+
+
+
+
+ 纳入统计文件
+ ${formatNumber(report.summary.totalFiles)}
+
+
+ 总代码/文档行数
+ ${formatNumber(report.summary.totalLoc)}
+
+
+ 聚合模块数
+ ${formatNumber(report.summary.totalModules)}
+
+
+ ${report.meta.sinceDays} 天 churn
+ ${formatNumber(report.summary.totalChurn)}
+
+
+
+
+
+
+
+ ${report.governance.candidates
+ .map((candidate) => {
+ const severityClass =
+ candidate.severity === "立即治理"
+ ? "urgent"
+ : candidate.severity === "尽快治理"
+ ? "warn"
+ : "";
+ return `
+
+
+ ${candidate.reasons
+ .map(
+ (reason) =>
+ `${escapeHtml(reason)}`,
+ )
+ .join("")}
+
+
+
LOC:${formatNumber(candidate.loc)}
+
文件:${formatNumber(candidate.fileCount)}
+
churn:${formatNumber(candidate.churn)}
+
活跃周:${formatNumber(candidate.activeWeekCount)}
+
+ ${escapeHtml(candidate.suggestion)}
+ `;
+ })
+ .join("")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 文件 |
+ 模块 |
+ LOC |
+ ${report.meta.sinceDays} 天 churn |
+ 热度密度 |
+ 最近触达 |
+
+
+
+
+
+
+
+
+
+
+ ${report.meta.notes
+ .map(
+ (note, index) => `
说明 ${index + 1}${escapeHtml(note)}
`,
+ )
+ .join("")}
+
+
+
+
+
+
+
+
+
+`;
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function formatTimestamp(isoString) {
+ const date = new Date(isoString);
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
+}
+
+function roundTo(value, precision) {
+ const scale = 10 ** precision;
+ return Math.round(value * scale) / scale;
+}
+
+function formatNumber(value) {
+ return new Intl.NumberFormat("zh-CN").format(value || 0);
+}
+
+function toPosixPath(filePath) {
+ return filePath.split(path.sep).join("/");
+}
diff --git a/scripts/report-legacy-surfaces.mjs b/scripts/report-legacy-surfaces.mjs
index 94ff53698..f207da2c2 100644
--- a/scripts/report-legacy-surfaces.mjs
+++ b/scripts/report-legacy-surfaces.mjs
@@ -3,6 +3,7 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
+import { fileURLToPath } from "node:url";
import agentCommandCatalog from "../src/lib/governance/agentCommandCatalog.json" with { type: "json" };
const repoRoot = path.resolve(process.cwd());
@@ -676,12 +677,12 @@ const rustTextSurfaceMonitors = [
},
{
id: "rust-agent-runtime-legacy-queue-migration-leak",
- classification: "dead-candidate",
+ classification: "deprecated",
description:
- "已零引用的 legacy runtime queue 启动迁移 helper 回流到其他模块",
+ "legacy runtime queue 启动迁移 helper 只允许停留在 aster runtime support 边界",
patterns: ["migrate_legacy_runtime_queue_to_aster_store("],
includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
- allowedPaths: [],
+ allowedPaths: ["src-tauri/crates/agent/src/aster_runtime_support.rs"],
},
{
id: "rust-agent-session-legacy-todo-state-leak",
@@ -803,6 +804,99 @@ const rustTextSurfaceMonitors = [
includePathPrefixes: ["src-tauri/src"],
allowedPaths: [],
},
+ {
+ id: "rust-agent-session-get-direct-read",
+ classification: "deprecated",
+ description: "session get_session 直读只允许统一 session_query helper 持有",
+ patterns: ["SessionManager::get_session("],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: ["src-tauri/crates/agent/src/session_query.rs"],
+ },
+ {
+ id: "rust-agent-subagent-child-session-direct-read",
+ classification: "deprecated",
+ description: "subagent child session 列表直读只允许统一 session_query helper 持有",
+ patterns: ["list_subagent_child_sessions("],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: ["src-tauri/crates/agent/src/session_query.rs"],
+ },
+ {
+ id: "rust-agent-subagent-session-list-direct-read",
+ classification: "deprecated",
+ description: "subagent 全量 session 列表直读只允许统一 session_query helper 持有",
+ patterns: ["list_subagent_sessions_with_metadata("],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: ["src-tauri/crates/agent/src/session_query.rs"],
+ },
+ {
+ id: "rust-agent-subagent-metadata-direct-read",
+ classification: "deprecated",
+ description: "subagent metadata 直读只允许 query 与 session_store 投影边界持有",
+ patterns: ["resolve_subagent_session_metadata("],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: [
+ "src-tauri/crates/agent/src/session_query.rs",
+ "src-tauri/crates/agent/src/session_store.rs",
+ ],
+ },
+ {
+ id: "rust-agent-session-extension-data-direct-update",
+ classification: "deprecated",
+ description:
+ "session extension_data 直写 builder 只允许统一 session_update helper 持有",
+ patterns: [],
+ regexPatterns: [
+ String.raw`SessionManager::update_session\([\s\S]*?\)\s*[\s\S]*?\.extension_data\([\s\S]*?\)\s*[\s\S]*?\.apply\(\)`,
+ ],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"],
+ },
+ {
+ id: "rust-agent-session-update-direct-call",
+ classification: "deprecated",
+ description: "session update_session 直写只允许统一 session_update helper 持有",
+ patterns: ["SessionManager::update_session("],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"],
+ },
+ {
+ id: "rust-agent-session-compaction-metrics-direct-update",
+ classification: "deprecated",
+ description:
+ "session compaction token 指标直写 builder 只允许统一 session_update helper 持有",
+ patterns: [],
+ regexPatterns: [
+ String.raw`SessionManager::update_session\([\s\S]*?\)\s*[\s\S]*?\.schedule_id\([\s\S]*?\)\s*[\s\S]*?\.total_tokens\([\s\S]*?\)\s*[\s\S]*?\.input_tokens\([\s\S]*?\)\s*[\s\S]*?\.output_tokens\([\s\S]*?\)\s*[\s\S]*?\.accumulated_total_tokens\([\s\S]*?\)\s*[\s\S]*?\.accumulated_input_tokens\([\s\S]*?\)\s*[\s\S]*?\.accumulated_output_tokens\([\s\S]*?\)\s*[\s\S]*?\.apply\(\)`,
+ ],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"],
+ },
+ {
+ id: "rust-agent-session-replace-conversation-direct-update",
+ classification: "deprecated",
+ description:
+ "session conversation 直写 replace_conversation 只允许统一 session_update helper 持有",
+ patterns: ["SessionManager::replace_conversation("],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"],
+ },
+ {
+ id: "rust-agent-session-create-direct-update",
+ classification: "deprecated",
+ description:
+ "session create_session 直写只允许统一 session_update helper 持有",
+ patterns: ["SessionManager::create_session("],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: ["src-tauri/crates/agent/src/session_update.rs"],
+ },
+ {
+ id: "rust-agent-session-delete-direct-update",
+ classification: "dead-candidate",
+ description: "已零引用的 session delete_session 直写回流",
+ patterns: ["SessionManager::delete_session("],
+ includePathPrefixes: ["src-tauri/src", "src-tauri/crates"],
+ allowedPaths: [],
+ },
{
id: "rust-agent-session-record-create-api-leak",
classification: "dead-candidate",
@@ -811,6 +905,14 @@ const rustTextSurfaceMonitors = [
includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"],
allowedPaths: [],
},
+ {
+ id: "rust-agent-command-subagent-metadata-direct-read",
+ classification: "dead-candidate",
+ description: "命令层不再 direct 解析 subagent metadata,统一向 lime_agent 读取边界收敛",
+ patterns: ["resolve_subagent_session_metadata("],
+ includePathPrefixes: ["src-tauri/src/commands/aster_agent_cmd"],
+ allowedPaths: [],
+ },
{
id: "rust-agent-tool-permission-public-module-leak",
classification: "dead-candidate",
@@ -1375,7 +1477,7 @@ function extractInvokeCommands(sourceCode) {
function stripRustTestModules(sourceCode) {
return sourceCode.replace(
- /(?:^|\n)\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\][\s\S]*$/m,
+ /(?:^|\n)\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]\s*(?:pub\s+)?mod\s+\w+\s*(?:\{[\s\S]*$|;)/m,
"\n",
);
}
@@ -1770,12 +1872,7 @@ function printTextReport(result) {
}
function printTextCountReport(result) {
- const status =
- result.violations.length > 0
- ? "违规"
- : result.runtimeMatches.length === 0
- ? "零引用"
- : "受控";
+ const status = getTextCountStatus(result);
console.log(
`- [${status}] ${result.id} (${result.classification}):${result.description}`,
@@ -1811,161 +1908,268 @@ function printTextCountReport(result) {
}
}
-const { runtimeSources, testSources } = collectSources();
-const {
- runtimeSources: frontendRuntimeTextSources,
- testSources: frontendTestTextSources,
-} = collectTextSources(sourceRoots, sourceExtensions);
-const { runtimeSources: rustRuntimeSources, testSources: rustTestSources } =
- collectTextSources(rustSourceRoots, rustSourceExtensions);
-const importResults = importSurfaceMonitors.map((monitor) =>
- evaluateImportMonitor(monitor, runtimeSources, testSources),
-);
-const commandResults = commandSurfaceMonitors.map((monitor) =>
- evaluateCommandMonitor(monitor, runtimeSources, testSources),
-);
-const frontendTextResults = frontendTextSurfaceMonitors.map((monitor) =>
- evaluateTextMonitor(
- monitor,
- frontendRuntimeTextSources,
- frontendTestTextSources,
- ),
-);
-const rustTextResults = rustTextSurfaceMonitors.map((monitor) =>
- evaluateTextMonitor(monitor, rustRuntimeSources, rustTestSources),
-);
-const rustTextCountResults = rustTextCountMonitors.map((monitor) =>
- evaluateTextCountMonitor(monitor, rustRuntimeSources, rustTestSources),
-);
-
-const zeroReferenceCandidates = importResults
- .filter(
- (result) =>
- result.references.length === 0 && result.existingTargets.length > 0,
- )
- .map((result) => `${result.id} (${result.description})`);
-const classificationDriftCandidates = [
- ...importResults
- .filter((result) =>
- isStatusClassificationDrift(
- getImportStatus(result),
- result.classification,
- ),
- )
- .map(
- (result) =>
- `${result.id} -> ${result.classification} / ${getImportStatus(result)}`,
- ),
- ...commandResults
- .filter((result) =>
- isStatusClassificationDrift(
- getCommandStatus(result),
- result.classification,
- ),
- )
- .map(
- (result) =>
- `${result.id} -> ${result.classification} / ${getCommandStatus(result)}`,
- ),
- ...frontendTextResults
- .filter((result) =>
- isStatusClassificationDrift(getTextStatus(result), result.classification),
- )
- .map(
- (result) =>
- `${result.id} -> ${result.classification} / ${getTextStatus(result)}`,
- ),
- ...rustTextResults
- .filter((result) =>
- isStatusClassificationDrift(getTextStatus(result), result.classification),
- )
- .map(
- (result) =>
- `${result.id} -> ${result.classification} / ${getTextStatus(result)}`,
- ),
- ...rustTextCountResults
- .filter((result) =>
- isStatusClassificationDrift(
- result.runtimeMatches.length === 0 ? "零引用" : "受控",
- result.classification,
- ),
- )
- .map(
- (result) =>
- `${result.id} -> ${result.classification} / ${
- result.runtimeMatches.length === 0 ? "零引用" : "受控"
- }`,
- ),
-];
-const violations = [
- ...importResults.flatMap((result) =>
- result.violations.map((item) => `${result.id} -> ${item}`),
- ),
- ...commandResults.flatMap((result) =>
- result.violations.map((item) => `${result.id} -> ${item}`),
- ),
- ...frontendTextResults.flatMap((result) =>
- result.violations.map((item) => `${result.id} -> ${item}`),
- ),
- ...rustTextResults.flatMap((result) =>
- result.violations.map((item) => `${result.id} -> ${item}`),
- ),
- ...rustTextCountResults.flatMap((result) =>
- result.violations.map((item) => `${result.id} -> ${item}`),
- ),
-];
-
-console.log("[lime] legacy surface report");
-console.log("");
-console.log("## 入口引用");
-for (const result of importResults) {
- printImportReport(result);
+function getTextCountStatus(result) {
+ return result.violations.length > 0
+ ? "违规"
+ : result.runtimeMatches.length === 0
+ ? "零引用"
+ : "受控";
}
-console.log("");
-console.log("## 命令边界");
-for (const result of commandResults) {
- printCommandReport(result);
-}
-
-console.log("");
-console.log("## 前端护栏");
-for (const result of frontendTextResults) {
- printTextReport(result);
-}
-
-console.log("");
-console.log("## Rust 护栏");
-for (const result of rustTextResults) {
- printTextReport(result);
-}
-for (const result of rustTextCountResults) {
- printTextCountReport(result);
-}
-
-console.log("");
-console.log("## 摘要");
-console.log(`- 扫描文件数:${runtimeSources.length}`);
-console.log(`- 测试文件数:${testSources.length}`);
-console.log(`- Rust 扫描文件数:${rustRuntimeSources.length}`);
-console.log(`- Rust 测试文件数:${rustTestSources.length}`);
-console.log(`- 零引用候选:${zeroReferenceCandidates.length}`);
-for (const candidate of zeroReferenceCandidates) {
- console.log(` - ${candidate}`);
-}
-console.log(`- 分类漂移候选:${classificationDriftCandidates.length}`);
-for (const candidate of classificationDriftCandidates) {
- console.log(` - ${candidate}`);
-}
-console.log(`- 边界违规:${violations.length}`);
-for (const violation of violations) {
- console.log(` - ${violation}`);
-}
-
-if (violations.length > 0) {
- console.error("");
- console.error(
- "[lime] legacy surface report 检测到边界违规,请先治理再继续扩展。",
+export function buildLegacySurfaceReport() {
+ const { runtimeSources, testSources } = collectSources();
+ const {
+ runtimeSources: frontendRuntimeTextSources,
+ testSources: frontendTestTextSources,
+ } = collectTextSources(sourceRoots, sourceExtensions);
+ const { runtimeSources: rustRuntimeSources, testSources: rustTestSources } =
+ collectTextSources(rustSourceRoots, rustSourceExtensions);
+ const importResults = importSurfaceMonitors.map((monitor) =>
+ evaluateImportMonitor(monitor, runtimeSources, testSources),
);
- process.exit(1);
+ const commandResults = commandSurfaceMonitors.map((monitor) =>
+ evaluateCommandMonitor(monitor, runtimeSources, testSources),
+ );
+ const frontendTextResults = frontendTextSurfaceMonitors.map((monitor) =>
+ evaluateTextMonitor(
+ monitor,
+ frontendRuntimeTextSources,
+ frontendTestTextSources,
+ ),
+ );
+ const rustTextResults = rustTextSurfaceMonitors.map((monitor) =>
+ evaluateTextMonitor(monitor, rustRuntimeSources, rustTestSources),
+ );
+ const rustTextCountResults = rustTextCountMonitors.map((monitor) =>
+ evaluateTextCountMonitor(monitor, rustRuntimeSources, rustTestSources),
+ );
+
+ const zeroReferenceCandidates = importResults
+ .filter(
+ (result) =>
+ result.references.length === 0 && result.existingTargets.length > 0,
+ )
+ .map((result) => `${result.id} (${result.description})`);
+ const classificationDriftCandidates = [
+ ...importResults
+ .filter((result) =>
+ isStatusClassificationDrift(
+ getImportStatus(result),
+ result.classification,
+ ),
+ )
+ .map(
+ (result) =>
+ `${result.id} -> ${result.classification} / ${getImportStatus(result)}`,
+ ),
+ ...commandResults
+ .filter((result) =>
+ isStatusClassificationDrift(
+ getCommandStatus(result),
+ result.classification,
+ ),
+ )
+ .map(
+ (result) =>
+ `${result.id} -> ${result.classification} / ${getCommandStatus(result)}`,
+ ),
+ ...frontendTextResults
+ .filter((result) =>
+ isStatusClassificationDrift(getTextStatus(result), result.classification),
+ )
+ .map(
+ (result) =>
+ `${result.id} -> ${result.classification} / ${getTextStatus(result)}`,
+ ),
+ ...rustTextResults
+ .filter((result) =>
+ isStatusClassificationDrift(getTextStatus(result), result.classification),
+ )
+ .map(
+ (result) =>
+ `${result.id} -> ${result.classification} / ${getTextStatus(result)}`,
+ ),
+ ...rustTextCountResults
+ .filter((result) =>
+ isStatusClassificationDrift(
+ getTextCountStatus(result),
+ result.classification,
+ ),
+ )
+ .map(
+ (result) =>
+ `${result.id} -> ${result.classification} / ${getTextCountStatus(result)}`,
+ ),
+ ];
+ const violations = [
+ ...importResults.flatMap((result) =>
+ result.violations.map((item) => `${result.id} -> ${item}`),
+ ),
+ ...commandResults.flatMap((result) =>
+ result.violations.map((item) => `${result.id} -> ${item}`),
+ ),
+ ...frontendTextResults.flatMap((result) =>
+ result.violations.map((item) => `${result.id} -> ${item}`),
+ ),
+ ...rustTextResults.flatMap((result) =>
+ result.violations.map((item) => `${result.id} -> ${item}`),
+ ),
+ ...rustTextCountResults.flatMap((result) =>
+ result.violations.map((item) => `${result.id} -> ${item}`),
+ ),
+ ];
+
+ return {
+ repoRoot,
+ runtimeSources,
+ testSources,
+ rustRuntimeSources,
+ rustTestSources,
+ importResults,
+ commandResults,
+ frontendTextResults,
+ rustTextResults,
+ rustTextCountResults,
+ zeroReferenceCandidates,
+ classificationDriftCandidates,
+ violations,
+ };
+}
+
+function serializeMapEntries(map) {
+ return Object.fromEntries(map.entries());
+}
+
+export function toSerializableLegacySurfaceReport(report) {
+ return {
+ repoRoot: report.repoRoot,
+ summary: {
+ runtimeSourceCount: report.runtimeSources.length,
+ testSourceCount: report.testSources.length,
+ rustRuntimeSourceCount: report.rustRuntimeSources.length,
+ rustTestSourceCount: report.rustTestSources.length,
+ zeroReferenceCandidates: report.zeroReferenceCandidates,
+ classificationDriftCandidates: report.classificationDriftCandidates,
+ violations: report.violations,
+ },
+ importResults: report.importResults,
+ commandResults: report.commandResults.map((result) => ({
+ ...result,
+ referencesByCommand: serializeMapEntries(result.referencesByCommand),
+ testReferencesByCommand: serializeMapEntries(result.testReferencesByCommand),
+ })),
+ frontendTextResults: report.frontendTextResults,
+ rustTextResults: report.rustTextResults,
+ rustTextCountResults: report.rustTextCountResults,
+ };
+}
+
+export function printLegacySurfaceReport(report) {
+ console.log("[lime] legacy surface report");
+ console.log("");
+ console.log("## 入口引用");
+ for (const result of report.importResults) {
+ printImportReport(result);
+ }
+
+ console.log("");
+ console.log("## 命令边界");
+ for (const result of report.commandResults) {
+ printCommandReport(result);
+ }
+
+ console.log("");
+ console.log("## 前端护栏");
+ for (const result of report.frontendTextResults) {
+ printTextReport(result);
+ }
+
+ console.log("");
+ console.log("## Rust 护栏");
+ for (const result of report.rustTextResults) {
+ printTextReport(result);
+ }
+ for (const result of report.rustTextCountResults) {
+ printTextCountReport(result);
+ }
+
+ console.log("");
+ console.log("## 摘要");
+ console.log(`- 扫描文件数:${report.runtimeSources.length}`);
+ console.log(`- 测试文件数:${report.testSources.length}`);
+ console.log(`- Rust 扫描文件数:${report.rustRuntimeSources.length}`);
+ console.log(`- Rust 测试文件数:${report.rustTestSources.length}`);
+ console.log(`- 零引用候选:${report.zeroReferenceCandidates.length}`);
+ for (const candidate of report.zeroReferenceCandidates) {
+ console.log(` - ${candidate}`);
+ }
+ console.log(`- 分类漂移候选:${report.classificationDriftCandidates.length}`);
+ for (const candidate of report.classificationDriftCandidates) {
+ console.log(` - ${candidate}`);
+ }
+ console.log(`- 边界违规:${report.violations.length}`);
+ for (const violation of report.violations) {
+ console.log(` - ${violation}`);
+ }
+}
+
+function parseCliArgs(argv) {
+ const options = {
+ json: false,
+ output: "",
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+
+ if (arg === "--json") {
+ options.json = true;
+ continue;
+ }
+
+ if (arg === "--output" && argv[index + 1]) {
+ options.output = String(argv[index + 1]).trim();
+ index += 1;
+ }
+ }
+
+ return options;
+}
+
+function runLegacySurfaceReportCli() {
+ const options = parseCliArgs(process.argv.slice(2));
+ const report = buildLegacySurfaceReport();
+
+ if (options.json) {
+ const serialized = JSON.stringify(
+ toSerializableLegacySurfaceReport(report),
+ null,
+ 2,
+ );
+ if (options.output) {
+ fs.mkdirSync(path.dirname(options.output), { recursive: true });
+ fs.writeFileSync(options.output, serialized, "utf8");
+ console.log(`[lime] legacy surface report JSON: ${options.output}`);
+ } else {
+ console.log(serialized);
+ }
+ } else {
+ printLegacySurfaceReport(report);
+ }
+
+ if (report.violations.length > 0) {
+ console.error("");
+ console.error(
+ "[lime] legacy surface report 检测到边界违规,请先治理再继续扩展。",
+ );
+ process.exit(1);
+ }
+}
+
+const isMainModule =
+ process.argv[1] &&
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+
+if (isMainModule) {
+ runLegacySurfaceReportCli();
}
diff --git a/scripts/setup-local-aster-override.mjs b/scripts/setup-local-aster-override.mjs
index 177590ee9..a65e45225 100644
--- a/scripts/setup-local-aster-override.mjs
+++ b/scripts/setup-local-aster-override.mjs
@@ -54,11 +54,9 @@ function validateAsterRepo(asterRepoPath) {
}
function buildConfigContent(asterRepoPath) {
- const asterPath = normalizePath(
- path.relative(cargoConfigDir, path.join(asterRepoPath, "crates", "aster")),
- );
+ const asterPath = normalizePath(path.join(asterRepoPath, "crates", "aster"));
const asterModelsPath = normalizePath(
- path.relative(cargoConfigDir, path.join(asterRepoPath, "crates", "aster-models")),
+ path.join(asterRepoPath, "crates", "aster-models"),
);
return `${blockStart}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index ba52461ad..a9481bee7 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -378,8 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "aster-core"
-version = "0.20.1"
-source = "git+https://github.com/astercloud/aster-rust?tag=v0.20.1#35f1bd92028e38df4edf5897f68a54369e929141"
+version = "0.21.0"
dependencies = [
"ahash",
"anyhow",
@@ -471,8 +470,7 @@ dependencies = [
[[package]]
name = "aster-models"
-version = "0.20.1"
-source = "git+https://github.com/astercloud/aster-rust?tag=v0.20.1#35f1bd92028e38df4edf5897f68a54369e929141"
+version = "0.21.0"
dependencies = [
"serde",
"serde_json",
@@ -2419,7 +2417,7 @@ dependencies = [
"dtoa-short",
"itoa",
"matches",
- "phf 0.10.1",
+ "phf 0.8.0",
"proc-macro2",
"quote",
"smallvec",
@@ -2435,7 +2433,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
- "phf 0.11.3",
+ "phf 0.8.0",
"smallvec",
]
@@ -4376,7 +4374,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.62.2",
+ "windows-core 0.56.0",
]
[[package]]
@@ -5103,7 +5101,7 @@ dependencies = [
[[package]]
name = "lime"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"anyhow",
"arboard",
@@ -5207,7 +5205,7 @@ dependencies = [
[[package]]
name = "lime-agent"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5236,7 +5234,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"chrono",
"futures",
@@ -5253,7 +5251,7 @@ dependencies = [
[[package]]
name = "lime-config"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5269,7 +5267,7 @@ dependencies = [
[[package]]
name = "lime-core"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5309,7 +5307,7 @@ dependencies = [
[[package]]
name = "lime-credential"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5344,7 +5342,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5374,7 +5372,7 @@ dependencies = [
[[package]]
name = "lime-infra"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5394,7 +5392,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5426,7 +5424,7 @@ dependencies = [
[[package]]
name = "lime-processor"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5445,7 +5443,7 @@ dependencies = [
[[package]]
name = "lime-providers"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5500,7 +5498,7 @@ dependencies = [
[[package]]
name = "lime-server"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5545,7 +5543,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5560,7 +5558,7 @@ dependencies = [
[[package]]
name = "lime-services"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5602,7 +5600,7 @@ dependencies = [
[[package]]
name = "lime-skills"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5620,7 +5618,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5647,7 +5645,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
-version = "0.94.0"
+version = "0.95.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -6326,7 +6324,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
- "proc-macro-crate 3.4.0",
+ "proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -7079,7 +7077,9 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
dependencies = [
+ "phf_macros 0.8.0",
"phf_shared 0.8.0",
+ "proc-macro-hack",
]
[[package]]
@@ -7088,9 +7088,7 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
dependencies = [
- "phf_macros 0.10.0",
"phf_shared 0.10.0",
- "proc-macro-hack",
]
[[package]]
@@ -7194,12 +7192,12 @@ dependencies = [
[[package]]
name = "phf_macros"
-version = "0.10.0"
+version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
+checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
dependencies = [
- "phf_generator 0.10.0",
- "phf_shared 0.10.0",
+ "phf_generator 0.8.0",
+ "phf_shared 0.8.0",
"proc-macro-hack",
"proc-macro2",
"quote",
@@ -7611,7 +7609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
- "itertools 0.14.0",
+ "itertools 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -9110,7 +9108,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
dependencies = [
- "dirs 6.0.0",
+ "dirs 4.0.0",
]
[[package]]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 1fac6e0b3..537f136be 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
-version = "0.94.0"
+version = "0.95.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -127,8 +127,8 @@ enigo = "0.3"
# 如需联调本地 aster-rust,请运行:
# npm run setup:local-aster -- /path/to/aster-rust
# 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。
-aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.20.1" }
-aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.20.1" }
+aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.21.0" }
+aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.21.0" }
# MCP (Model Context Protocol)
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
@@ -192,7 +192,7 @@ version = "2.4"
[package]
name = "lime"
-version = "0.94.0"
+version = "0.95.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
diff --git a/src-tauri/crates/agent/src/aster_runtime_support.rs b/src-tauri/crates/agent/src/aster_runtime_support.rs
index 87f0e15df..dbe3fb350 100644
--- a/src-tauri/crates/agent/src/aster_runtime_support.rs
+++ b/src-tauri/crates/agent/src/aster_runtime_support.rs
@@ -150,6 +150,16 @@ pub(crate) async fn prepare_aster_runtime_queue_resumption() -> Result,
+) -> Result<(), String> {
+ for queued_turn in queued_turns {
+ enqueue_aster_runtime_turn(queued_turn).await?;
+ }
+ Ok(())
+}
+
pub(crate) async fn enqueue_aster_runtime_turn(
queued_turn: QueuedTurnRuntime,
) -> Result {
diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs
index 21182c415..5cd4f1508 100644
--- a/src-tauri/crates/agent/src/event_converter.rs
+++ b/src-tauri/crates/agent/src/event_converter.rs
@@ -604,6 +604,24 @@ pub enum TauriAgentEvent {
#[serde(rename = "context_trace")]
ContextTrace { steps: Vec },
+ /// 上下文压缩开始
+ #[serde(rename = "context_compaction_started")]
+ ContextCompactionStarted {
+ item_id: String,
+ trigger: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ detail: Option,
+ },
+
+ /// 上下文压缩完成
+ #[serde(rename = "context_compaction_completed")]
+ ContextCompactionCompleted {
+ item_id: String,
+ trigger: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ detail: Option,
+ },
+
/// 当前回合运行态摘要
#[serde(rename = "runtime_status")]
RuntimeStatus { status: TauriRuntimeStatus },
@@ -822,12 +840,7 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec {
AgentEvent::ModelChange { model, mode } => {
vec![TauriAgentEvent::ModelChange { model, mode }]
}
- AgentEvent::HistoryReplaced(_conversation) => vec![TauriAgentEvent::ContextTrace {
- steps: vec![TauriContextTraceStep {
- stage: "context_management".to_string(),
- detail: "会话历史已自动压缩,以继续当前对话。".to_string(),
- }],
- }],
+ AgentEvent::HistoryReplaced(_conversation) => vec![],
AgentEvent::ContextTrace { steps } => vec![TauriAgentEvent::ContextTrace {
steps: steps
.into_iter()
@@ -837,6 +850,28 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec {
})
.collect(),
}],
+ AgentEvent::ContextCompactionStarted {
+ item_id,
+ trigger,
+ detail,
+ } => vec![TauriAgentEvent::ContextCompactionStarted {
+ item_id,
+ trigger,
+ detail,
+ }],
+ AgentEvent::ContextCompactionCompleted {
+ item_id,
+ trigger,
+ detail,
+ } => vec![TauriAgentEvent::ContextCompactionCompleted {
+ item_id,
+ trigger,
+ detail,
+ }],
+ AgentEvent::ContextCompactionWarning { message } => vec![TauriAgentEvent::Warning {
+ code: Some("context_compaction_accuracy".to_string()),
+ message,
+ }],
}
}
@@ -1439,18 +1474,67 @@ mod tests {
}
#[test]
- fn test_convert_history_replaced_to_context_management_trace() {
+ fn test_convert_history_replaced_returns_empty_for_runtime_projection() {
let event = AgentEvent::HistoryReplaced(aster::conversation::Conversation::empty());
let events = convert_agent_event(event);
- assert_eq!(events.len(), 1);
- match &events[0] {
- TauriAgentEvent::ContextTrace { steps } => {
- assert_eq!(steps.len(), 1);
- assert_eq!(steps[0].stage, "context_management");
- assert!(steps[0].detail.contains("自动压缩"));
+ assert!(events.is_empty());
+ }
+
+ #[test]
+ fn test_convert_context_compaction_lifecycle_events() {
+ let started_events = convert_agent_event(AgentEvent::ContextCompactionStarted {
+ item_id: "compact-1".to_string(),
+ trigger: "manual".to_string(),
+ detail: Some("压缩最近 8 轮历史".to_string()),
+ });
+ assert_eq!(started_events.len(), 1);
+ match &started_events[0] {
+ TauriAgentEvent::ContextCompactionStarted {
+ item_id,
+ trigger,
+ detail,
+ } => {
+ assert_eq!(item_id, "compact-1");
+ assert_eq!(trigger, "manual");
+ assert_eq!(detail.as_deref(), Some("压缩最近 8 轮历史"));
}
- _ => panic!("Expected ContextTrace event"),
+ other => panic!("Expected ContextCompactionStarted event, got {other:?}"),
+ }
+
+ let completed_events = convert_agent_event(AgentEvent::ContextCompactionCompleted {
+ item_id: "compact-1".to_string(),
+ trigger: "auto".to_string(),
+ detail: Some("已生成摘要并替换旧上下文".to_string()),
+ });
+ assert_eq!(completed_events.len(), 1);
+ match &completed_events[0] {
+ TauriAgentEvent::ContextCompactionCompleted {
+ item_id,
+ trigger,
+ detail,
+ } => {
+ assert_eq!(item_id, "compact-1");
+ assert_eq!(trigger, "auto");
+ assert_eq!(detail.as_deref(), Some("已生成摘要并替换旧上下文"));
+ }
+ other => panic!("Expected ContextCompactionCompleted event, got {other:?}"),
+ }
+
+ let warning_events = convert_agent_event(AgentEvent::ContextCompactionWarning {
+ message: "长对话和多次上下文压缩会降低模型准确性;如果后续结果开始漂移,建议新开会话。"
+ .to_string(),
+ });
+ assert_eq!(warning_events.len(), 1);
+ match &warning_events[0] {
+ TauriAgentEvent::Warning { code, message } => {
+ assert_eq!(code.as_deref(), Some("context_compaction_accuracy"));
+ assert_eq!(
+ message,
+ "长对话和多次上下文压缩会降低模型准确性;如果后续结果开始漂移,建议新开会话。"
+ );
+ }
+ other => panic!("Expected Warning event, got {other:?}"),
}
}
diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs
index fd19bad5f..a1fcae28e 100644
--- a/src-tauri/crates/agent/src/lib.rs
+++ b/src-tauri/crates/agent/src/lib.rs
@@ -30,8 +30,10 @@ pub mod queued_turn;
pub mod request_tool_policy;
pub mod runtime_projection_snapshot;
pub mod runtime_queue;
+mod session_query;
pub mod session_state_snapshot;
mod session_store;
+mod session_update;
pub mod skill_execution;
pub mod subagent_control;
pub mod subagent_profiles;
@@ -44,7 +46,7 @@ pub mod turn_state;
mod write_artifact_events;
pub use ask_bridge::{create_ask_callback, extract_response as extract_ask_response};
-pub use aster_runtime_support::initialize_aster_runtime;
+pub use aster_runtime_support::{initialize_aster_runtime, restore_aster_runtime_queued_turns};
pub use aster_state::{AsterAgentState, ProviderConfig, QueuedTurnTask};
pub use aster_state_support::{
build_project_system_prompt, create_lime_identity, create_lime_tool_config, message_helpers,
@@ -92,6 +94,10 @@ pub use runtime_queue::{
resume_runtime_queue_if_needed, submit_runtime_turn, RuntimeQueueEventEmitter,
RuntimeQueueExecutor,
};
+pub use session_query::{
+ collect_subagent_cascade_session_ids, list_child_subagent_sessions,
+ list_subagent_cascade_session_ids, list_subagent_status_scope_session_ids, read_session,
+};
pub use session_state_snapshot::SessionStateSnapshot;
pub use session_store::{
create_session_sync, delete_session, get_persisted_session_metadata_sync,
@@ -101,13 +107,16 @@ pub use session_store::{
PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage,
SessionTodoItem, SubagentParentContext,
};
+pub use session_update::{
+ create_subagent_session, persist_compaction_session_metrics_update,
+ persist_session_extension_data, replace_session_conversation, CompactionSessionMetricsUpdate,
+};
pub use skill_execution::{
execute_skill_prompt, execute_skill_workflow, SkillEventEmitter, SkillExecutionError,
SkillExecutionResult, SkillWorkflowExecution, StepResult,
};
pub use subagent_control::{
- collect_subagent_cascade_session_ids, derive_subagent_runtime_status_kind,
- list_subagent_cascade_session_ids, load_subagent_runtime_status, read_subagent_control_state,
+ derive_subagent_runtime_status_kind, load_subagent_runtime_status, read_subagent_control_state,
write_subagent_control_state, SubagentControlState, SubagentRuntimeStatus,
SubagentRuntimeStatusInput, SubagentRuntimeStatusKind,
};
diff --git a/src-tauri/crates/agent/src/prompt/runtime_agents.rs b/src-tauri/crates/agent/src/prompt/runtime_agents.rs
index 28727f2ee..82cec0595 100644
--- a/src-tauri/crates/agent/src/prompt/runtime_agents.rs
+++ b/src-tauri/crates/agent/src/prompt/runtime_agents.rs
@@ -1,8 +1,8 @@
//! Lime 运行时 AGENTS 指令加载
//!
//! 仅用于 Lime 应用运行时会话:
-//! - 全局:`~/.lime/AGENTS.md`
-//! - 工作区:`/.lime/AGENTS.md`
+//! - 全局:`app_paths::resolve_user_memory_path()`
+//! - 工作区:当前 workspace 的 runtime AGENTS 文件
use lime_core::app_paths;
use std::collections::HashSet;
@@ -33,7 +33,7 @@ pub fn merge_system_prompt_with_runtime_agents(
pub fn build_runtime_agents_prompt(working_dir: Option<&Path>) -> Option {
let global_path = app_paths::best_effort_user_memory_path();
- let workspace_path = working_dir.map(|dir| dir.join(".lime").join("AGENTS.md"));
+ let workspace_path = working_dir.map(app_paths::resolve_workspace_runtime_agents_path);
build_runtime_agents_prompt_with_paths(Some(global_path.as_path()), workspace_path.as_deref())
}
@@ -99,7 +99,8 @@ mod tests {
fn should_build_prompt_with_global_and_workspace_layers() {
let tmp = TempDir::new().expect("create temp dir");
let global_path = tmp.path().join("global").join("AGENTS.md");
- let workspace_path = tmp.path().join("workspace").join(".lime").join("AGENTS.md");
+ let workspace_root = tmp.path().join("workspace");
+ let workspace_path = app_paths::resolve_workspace_runtime_agents_path(&workspace_root);
fs::create_dir_all(global_path.parent().expect("global parent")).expect("create global");
fs::create_dir_all(workspace_path.parent().expect("workspace parent"))
.expect("create workspace");
diff --git a/src-tauri/crates/agent/src/session_query.rs b/src-tauri/crates/agent/src/session_query.rs
new file mode 100644
index 000000000..a9cb9185f
--- /dev/null
+++ b/src-tauri/crates/agent/src/session_query.rs
@@ -0,0 +1,167 @@
+use aster::session::{
+ list_subagent_child_sessions, list_subagent_sessions_with_metadata,
+ resolve_subagent_session_metadata, Session, SessionManager, SessionType,
+};
+use std::collections::{HashMap, HashSet, VecDeque};
+
+fn normalize_optional_text(value: Option) -> Option {
+ let trimmed = value?.trim().to_string();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed)
+ }
+}
+
+pub(crate) fn ensure_subagent_session(session: &Session) -> Result<(), String> {
+ if session.session_type != SessionType::SubAgent {
+ return Err(format!(
+ "会话不是 subagent session: session_id={}, session_type={}",
+ session.id, session.session_type
+ ));
+ }
+ Ok(())
+}
+
+pub async fn read_session(
+ session_id: &str,
+ with_conversation: bool,
+ error_context: &str,
+) -> Result {
+ SessionManager::get_session(session_id, with_conversation)
+ .await
+ .map_err(|error| format!("{error_context}: {error}"))
+}
+
+pub async fn list_child_subagent_sessions(
+ parent_session_id: &str,
+ error_context: &str,
+) -> Result, String> {
+ list_subagent_child_sessions(parent_session_id)
+ .await
+ .map_err(|error| format!("{error_context}: {error}"))
+}
+
+async fn list_subagent_sessions_with_metadata_query() -> Result, String> {
+ list_subagent_sessions_with_metadata()
+ .await
+ .map_err(|error| format!("读取 subagent session 列表失败: {error}"))
+}
+
+pub(crate) async fn read_subagent_session(
+ session_id: &str,
+ error_context: &str,
+) -> Result {
+ let session = read_session(session_id, false, error_context).await?;
+ ensure_subagent_session(&session)?;
+ Ok(session)
+}
+
+pub(crate) fn resolve_subagent_parent_session_id(session: &Session) -> Option {
+ let metadata = resolve_subagent_session_metadata(&session.extension_data)?;
+ normalize_optional_text(Some(metadata.parent_session_id))
+}
+
+pub async fn list_subagent_status_scope_session_ids(session_id: &str) -> Vec {
+ let mut scope_ids = Vec::new();
+ let mut seen = HashSet::new();
+ let mut current_session_id = session_id.to_string();
+
+ while seen.insert(current_session_id.clone()) {
+ scope_ids.push(current_session_id.clone());
+
+ let session = match read_session(¤t_session_id, false, "解析 team 事件 scope 失败")
+ .await
+ {
+ Ok(session) => session,
+ Err(error) => {
+ tracing::warn!(
+ "[SessionQuery] 解析 team 事件 scope 失败: session_id={}, error={}",
+ current_session_id,
+ error
+ );
+ break;
+ }
+ };
+ let Some(parent_session_id) = resolve_subagent_parent_session_id(&session) else {
+ break;
+ };
+ current_session_id = parent_session_id;
+ }
+
+ scope_ids
+}
+
+pub async fn list_subagent_cascade_session_ids(session_id: &str) -> Result, String> {
+ let _ = read_subagent_session(session_id, "读取 subagent session 失败").await?;
+ let sessions = list_subagent_sessions_with_metadata_query().await?;
+ Ok(collect_subagent_cascade_session_ids(session_id, &sessions))
+}
+
+pub fn collect_subagent_cascade_session_ids(session_id: &str, sessions: &[Session]) -> Vec {
+ let mut children_by_parent: HashMap> = HashMap::new();
+ for session in sessions {
+ let Some(parent_session_id) = resolve_subagent_parent_session_id(session) else {
+ continue;
+ };
+ children_by_parent
+ .entry(parent_session_id)
+ .or_default()
+ .push(session.id.clone());
+ }
+
+ let mut ordered = vec![session_id.to_string()];
+ let mut queue = VecDeque::from([session_id.to_string()]);
+ while let Some(parent_id) = queue.pop_front() {
+ let Some(children) = children_by_parent.get(&parent_id) else {
+ continue;
+ };
+ for child_id in children {
+ ordered.push(child_id.clone());
+ queue.push_back(child_id.clone());
+ }
+ }
+ ordered
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::{Duration, Utc};
+
+ #[test]
+ fn collect_subagent_cascade_session_ids_returns_breadth_first_tree() {
+ let now = Utc::now();
+ let child_a = Session {
+ id: "child-a".to_string(),
+ session_type: SessionType::SubAgent,
+ updated_at: now,
+ extension_data: aster::session::SubagentSessionMetadata::new("root")
+ .into_updated_extension_data(&Session::default())
+ .unwrap(),
+ ..Session::default()
+ };
+ let child_b = Session {
+ id: "child-b".to_string(),
+ session_type: SessionType::SubAgent,
+ updated_at: now - Duration::minutes(1),
+ extension_data: aster::session::SubagentSessionMetadata::new("root")
+ .into_updated_extension_data(&Session::default())
+ .unwrap(),
+ ..Session::default()
+ };
+ let grandchild = Session {
+ id: "grandchild".to_string(),
+ session_type: SessionType::SubAgent,
+ updated_at: now - Duration::minutes(2),
+ extension_data: aster::session::SubagentSessionMetadata::new("child-a")
+ .into_updated_extension_data(&Session::default())
+ .unwrap(),
+ ..Session::default()
+ };
+
+ let ids = collect_subagent_cascade_session_ids("root", &[child_a, child_b, grandchild]);
+
+ assert_eq!(ids, vec!["root", "child-a", "child-b", "grandchild"]);
+ }
+}
diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs
index a3d9455e4..91971f06a 100644
--- a/src-tauri/crates/agent/src/session_store.rs
+++ b/src-tauri/crates/agent/src/session_store.rs
@@ -5,8 +5,7 @@
use aster::session::extension_data::{resolve_todo_list_state, TodoListItem, TodoListItemStatus};
use aster::session::{
- list_subagent_child_sessions, resolve_subagent_session_metadata, Session as AsterSession,
- SessionManager, SessionRuntimeSnapshot,
+ resolve_subagent_session_metadata, Session as AsterSession, SessionRuntimeSnapshot,
};
use chrono::Utc;
use lime_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent};
@@ -28,6 +27,7 @@ use crate::aster_runtime_support::load_aster_runtime_snapshot;
use crate::event_converter::{
convert_item_runtime, convert_turn_runtime, TauriMessage, TauriMessageContent,
};
+use crate::session_query::{list_child_subagent_sessions, read_session};
use crate::subagent_control::{load_subagent_runtime_status, SubagentRuntimeStatusKind};
use crate::subagent_profiles::{SubagentCustomizationState, SubagentSkillSummary};
use crate::tool_io_offload::{
@@ -142,6 +142,57 @@ pub struct ChildSubagentSession {
pub retryable_overload: bool,
}
+impl ChildSubagentSession {
+ fn new_base(
+ id: String,
+ name: String,
+ created_at: i64,
+ updated_at: i64,
+ session_type: String,
+ model: Option,
+ provider_name: Option,
+ working_dir: Option,
+ workspace_id: Option,
+ ) -> Self {
+ Self {
+ id,
+ name,
+ created_at,
+ updated_at,
+ session_type,
+ model,
+ provider_name,
+ working_dir,
+ workspace_id,
+ task_summary: None,
+ role_hint: None,
+ origin_tool: None,
+ created_from_turn_id: None,
+ blueprint_role_id: None,
+ blueprint_role_label: None,
+ profile_id: None,
+ profile_name: None,
+ role_key: None,
+ team_preset_id: None,
+ theme: None,
+ output_contract: None,
+ skill_ids: Vec::new(),
+ skills: Vec::new(),
+ runtime_status: None,
+ latest_turn_status: None,
+ queued_turn_count: 0,
+ team_phase: None,
+ team_parallel_budget: None,
+ team_active_count: None,
+ team_queued_count: None,
+ provider_concurrency_group: None,
+ provider_parallel_budget: None,
+ queue_reason: None,
+ retryable_overload: false,
+ }
+ }
+}
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SubagentParentContext {
pub parent_session_id: String,
@@ -378,12 +429,111 @@ fn resolve_child_subagent_runtime_status_from_snapshot(
.unwrap_or(ChildSubagentRuntimeStatus::Idle)
}
+#[derive(Debug, Clone, Default)]
+struct SubagentPresentationProjection {
+ parent_session_id: String,
+ task_summary: Option,
+ role_hint: Option,
+ origin_tool: Option,
+ created_from_turn_id: Option,
+ blueprint_role_id: Option,
+ blueprint_role_label: Option,
+ profile_id: Option,
+ profile_name: Option,
+ role_key: Option,
+ team_preset_id: Option,
+ theme: Option,
+ output_contract: Option,
+ skill_ids: Vec,
+ skills: Vec,
+}
+
+impl SubagentPresentationProjection {
+ fn from_session(session: &AsterSession) -> Option {
+ let metadata = resolve_subagent_session_metadata(&session.extension_data)?;
+ let customization = SubagentCustomizationState::from_session(session).unwrap_or_default();
+ Some(Self {
+ parent_session_id: metadata.parent_session_id,
+ task_summary: normalize_optional_nonempty_body(metadata.task_summary),
+ role_hint: normalize_optional_text(metadata.role_hint),
+ origin_tool: normalize_optional_text(Some(metadata.origin_tool)),
+ created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id),
+ blueprint_role_id: customization.blueprint_role_id,
+ blueprint_role_label: customization.blueprint_role_label,
+ profile_id: customization.profile_id,
+ profile_name: customization.profile_name,
+ role_key: customization.role_key,
+ team_preset_id: customization.team_preset_id,
+ theme: customization.theme,
+ output_contract: customization.output_contract,
+ skill_ids: customization.skill_ids,
+ skills: customization.skills,
+ })
+ }
+
+ fn apply_to_child_summary(self, summary: &mut ChildSubagentSession) {
+ summary.task_summary = self.task_summary;
+ summary.role_hint = self.role_hint;
+ summary.origin_tool = self.origin_tool;
+ summary.created_from_turn_id = self.created_from_turn_id;
+ summary.blueprint_role_id = self.blueprint_role_id;
+ summary.blueprint_role_label = self.blueprint_role_label;
+ summary.profile_id = self.profile_id;
+ summary.profile_name = self.profile_name;
+ summary.role_key = self.role_key;
+ summary.team_preset_id = self.team_preset_id;
+ summary.theme = self.theme;
+ summary.output_contract = self.output_contract;
+ summary.skill_ids = self.skill_ids;
+ summary.skills = self.skills;
+ }
+
+ fn into_parent_context(
+ self,
+ parent_session_name: String,
+ current_session_id: &str,
+ sibling_subagent_sessions: Vec,
+ ) -> SubagentParentContext {
+ SubagentParentContext {
+ parent_session_id: self.parent_session_id,
+ parent_session_name,
+ role_hint: self.role_hint,
+ task_summary: self.task_summary,
+ origin_tool: self.origin_tool,
+ created_from_turn_id: self.created_from_turn_id,
+ blueprint_role_id: self.blueprint_role_id,
+ blueprint_role_label: self.blueprint_role_label,
+ profile_id: self.profile_id,
+ profile_name: self.profile_name,
+ role_key: self.role_key,
+ team_preset_id: self.team_preset_id,
+ theme: self.theme,
+ output_contract: self.output_contract,
+ skill_ids: self.skill_ids,
+ skills: self.skills,
+ sibling_subagent_sessions: filter_sibling_subagent_sessions(
+ current_session_id,
+ sibling_subagent_sessions,
+ ),
+ }
+ }
+}
+
+fn filter_sibling_subagent_sessions(
+ current_session_id: &str,
+ sibling_subagent_sessions: Vec,
+) -> Vec {
+ sibling_subagent_sessions
+ .into_iter()
+ .filter(|session| session.id != current_session_id)
+ .collect()
+}
+
fn build_child_subagent_session_summary(
db: Option<&DbConnection>,
session: AsterSession,
) -> Option {
- let metadata = resolve_subagent_session_metadata(&session.extension_data)?;
- let customization = SubagentCustomizationState::from_session(&session).unwrap_or_default();
+ let projection = SubagentPresentationProjection::from_session(&session)?;
let working_dir =
normalize_optional_text(Some(session.working_dir.to_string_lossy().to_string()));
let workspace_id =
@@ -393,42 +543,19 @@ fn build_child_subagent_session_summary(
let name = normalize_optional_text(Some(session.name.clone()))
.unwrap_or_else(|| "子代理会话".to_string());
- Some(ChildSubagentSession {
- id: session.id,
+ let mut summary = ChildSubagentSession::new_base(
+ session.id,
name,
- created_at: session.created_at.timestamp(),
- updated_at: session.updated_at.timestamp(),
- session_type: session.session_type.to_string(),
+ session.created_at.timestamp(),
+ session.updated_at.timestamp(),
+ session.session_type.to_string(),
model,
provider_name,
working_dir,
workspace_id,
- task_summary: normalize_optional_nonempty_body(metadata.task_summary),
- role_hint: normalize_optional_text(metadata.role_hint),
- origin_tool: normalize_optional_text(Some(metadata.origin_tool)),
- created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id),
- blueprint_role_id: customization.blueprint_role_id,
- blueprint_role_label: customization.blueprint_role_label,
- profile_id: customization.profile_id,
- profile_name: customization.profile_name,
- role_key: customization.role_key,
- team_preset_id: customization.team_preset_id,
- theme: customization.theme,
- output_contract: customization.output_contract,
- skill_ids: customization.skill_ids,
- skills: customization.skills,
- runtime_status: None,
- latest_turn_status: None,
- queued_turn_count: 0,
- team_phase: None,
- team_parallel_budget: None,
- team_active_count: None,
- team_queued_count: None,
- provider_concurrency_group: None,
- provider_parallel_budget: None,
- queue_reason: None,
- retryable_overload: false,
- })
+ );
+ projection.apply_to_child_summary(&mut summary);
+ Some(summary)
}
fn apply_runtime_status_to_child_subagent_session(
@@ -471,46 +598,26 @@ fn build_child_subagent_session_summaries(
fn build_subagent_parent_context(
current_session_id: &str,
parent_session: Option<&AsterSession>,
- metadata: aster::session::SubagentSessionMetadata,
- customization: Option,
+ projection: SubagentPresentationProjection,
sibling_subagent_sessions: Vec,
) -> SubagentParentContext {
let parent_session_name = parent_session
.and_then(|session| normalize_optional_text(Some(session.name.clone())))
.unwrap_or_else(|| "父会话".to_string());
- let customization = customization.unwrap_or_default();
- SubagentParentContext {
- parent_session_id: metadata.parent_session_id,
+ projection.into_parent_context(
parent_session_name,
- role_hint: normalize_optional_text(metadata.role_hint),
- task_summary: normalize_optional_nonempty_body(metadata.task_summary),
- origin_tool: normalize_optional_text(Some(metadata.origin_tool)),
- created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id),
- blueprint_role_id: customization.blueprint_role_id,
- blueprint_role_label: customization.blueprint_role_label,
- profile_id: customization.profile_id,
- profile_name: customization.profile_name,
- role_key: customization.role_key,
- team_preset_id: customization.team_preset_id,
- theme: customization.theme,
- output_contract: customization.output_contract,
- skill_ids: customization.skill_ids,
- skills: customization.skills,
- sibling_subagent_sessions: sibling_subagent_sessions
- .into_iter()
- .filter(|session| session.id != current_session_id)
- .collect(),
- }
+ current_session_id,
+ sibling_subagent_sessions,
+ )
}
async fn load_child_subagent_sessions(
db: &DbConnection,
session_id: &str,
) -> Result, String> {
- let sessions = list_subagent_child_sessions(session_id)
- .await
- .map_err(|error| format!("读取 child subagent sessions 失败: {error}"))?;
+ let sessions =
+ list_child_subagent_sessions(session_id, "读取 child subagent sessions 失败").await?;
let mut summaries = build_child_subagent_session_summaries(Some(db), sessions);
for summary in &mut summaries {
match load_subagent_runtime_status(&summary.id).await {
@@ -531,39 +638,35 @@ async fn load_subagent_parent_context(
db: &DbConnection,
session_id: &str,
) -> Result