dsh-arcvista 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-11-02
4
+
5
+ - Release 0.2.0.
6
+
7
+ ## 0.1.6 - 2026-11-02
8
+
9
+ - Release 0.1.6.
10
+
3
11
  ## 0.1.5 - 2026-11-02
4
12
 
5
13
  - Release 0.1.5.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # ArcVista · 观界
2
2
 
3
- 本地优先的架构观测台:接入一个 Git 仓库,自动生成**可下钻的系统架构图、安全架构守护清单、业务知识图谱、复杂度与测试覆盖度**,并支持**对比不同分支之间的架构漂移与风险**。
3
+ 本地优先的架构观测台:选择一个本地项目目录,自动生成**可下钻的系统架构图、安全架构守护清单、业务知识图谱、复杂度与测试覆盖度**,并支持**对比不同快照之间的架构漂移与风险**。
4
4
 
5
- 零第三方依赖,只用 Node 22 内置的 `node:http` + `node:sqlite`,数据保存在本机 SQLite;内置 6 套可切换皮肤。
5
+ 零第三方依赖,只用 Node 22 内置的 `node:http` 与本地 JSON 文件;项目列表在插件数据目录,分析产出在项目自己的 `.arcvista/` 目录。内置 6 套可切换皮肤。
6
6
 
7
7
  ![系统架构图](docs/screenshots/architecture.png)
8
8
 
@@ -13,7 +13,7 @@
13
13
  1. 这个系统现在**长什么样**(分层、边界、服务之间的真实调用与数据依赖);
14
14
  2. 它有哪些**架构守护**(认证、授权、注入防护、扫描规则、CI 门禁),这些规则是否可追溯;
15
15
  3. 它的**业务知识**如何组织(业务域、能力、实体、事件、主流程);
16
- 4. 换一个分支之后,架构、守护规则、依赖、复杂度和覆盖率**发生了什么变化,是否存在风险**。
16
+ 4. 重新分析当前工作区之后,架构、守护规则、依赖、复杂度和覆盖率**发生了什么变化,是否存在风险**。
17
17
 
18
18
  ArcVista 把这些问题做成一个可以直接下钻的看板,而不是一堆静态报告。
19
19
 
@@ -25,7 +25,7 @@ ArcVista 把这些问题做成一个可以直接下钻的看板,而不是一
25
25
 
26
26
  - **不需要单独启动 Node 服务**:ArcVista 的 API 和静态页面由 DSH Host 插件直接挂载在 `/arcvista` 路径下;
27
27
  - **不需要配置模型**:分析使用 DSH 当前选择的默认模型(例如 `deepseek-flash`),模型调用通过 DSH 的 `llm` 服务完成;设置页不提供模型配置;
28
- - 数据仍保存在本机:默认写在 `~/.dsh/arcvista/`(SQLite、备份和仓库镜像),不会写进 `node_modules`;
28
+ - 数据仍保存在本机:项目列表、任务与设置写在 `~/.dsh/arcvista/`;分析快照写在每个项目的 `.arcvista/`;
29
29
  - 面板顶部显示运行状态,右上角可以刷新或在独立浏览器窗口打开;
30
30
  - 主题跟随 DSH 的浅色 / 深色模式(映射到 `daylight` / `obsidian`),内嵌页面隐藏了 ArcVista 原来的左侧栏;
31
31
  - 插件随 DSH 启动而加载,无需手动维护端口或进程。
@@ -88,8 +88,8 @@ npm run package:security-review
88
88
 
89
89
  这个插件会在 DSH Host 进程里执行以下操作,安装前请确认:
90
90
 
91
- - 用 `git` 克隆 / 拉取被观测仓库,读取工作区文件,生成静态分析结果;
92
- - 在 DSH profile 的 `arcvista/` 下写 SQLite、备份和任务状态;
91
+ - 读取用户选择的本地项目目录(只读源码);如果目录是 Git 仓库,会调用本机 `git` 读取当前分支、提交与变更热度;
92
+ - 在全局插件目录 `~/.dsh/arcvista/` 写项目列表、任务与设置;在项目内 `.arcvista/` 写分析快照与下钻缓存;
93
93
  - 通过 DSH 的 `llm` 服务调用当前默认模型做语义增强;
94
94
  - 在 DSH Web 服务器注册 `/arcvista` 路由,用于面板和 API。
95
95
 
@@ -97,22 +97,22 @@ npm run package:security-review
97
97
 
98
98
  ## 快速开始
99
99
 
100
- 要求:Node.js **>= 22.5**(使用内置 `node:sqlite`),本机安装 `git`。
100
+ 要求:Node.js **>= 22.19**,本机安装 `git`(可选;非 Git 目录也能分析)。
101
101
 
102
102
  ```bash
103
103
  cd arc-vista
104
104
  npm start
105
105
  ```
106
106
 
107
- 打开 <http://127.0.0.1:4321>,点击「新建观测目标」,填入:
107
+ 打开 <http://127.0.0.1:4321>,点击「新建本地项目」,填入:
108
108
 
109
- - 产品名称;
110
- - Git 仓库地址(支持 `https://` / `ssh://` / `git@` / 本地绝对路径);
111
- - 可选分支与描述。
109
+ - 产品名称(可留空,默认取目录名);
110
+ - 本地项目目录的绝对路径;DSH 插件里也可以直接从已登记的工作区选择;
111
+ - 可选描述。
112
112
 
113
- 创建后会先在后台克隆 / 拉取仓库,完成后进入产品的「分支分析清单」:勾选要分析的分支(可多选)后开始分析,任务会按队列依次执行,全部分析完成后进入看板。
113
+ 创建后会立即分析当前工作区,不需要克隆、拉取或手动准备仓库。分析产出写入项目内的 `.arcvista/`。
114
114
 
115
- > 不需要 `npm install`:项目没有任何运行时依赖。第一次分析需要能访问仓库地址。
115
+ > 不需要 `npm install`:项目没有任何运行时依赖。
116
116
 
117
117
  ## 皮肤
118
118
 
@@ -133,21 +133,18 @@ npm start
133
133
 
134
134
  ## 功能
135
135
 
136
- ### 0. 仓库准备与分支分析清单
136
+ ### 0. 本地项目接入
137
137
 
138
- ![分支分析清单](docs/screenshots/branch-analysis.png)
139
-
140
- - 新建产品后先显示克隆 / 拉取进度,完成后自动刷新出分支清单;
141
- - 每个分支显示最新提交、是否已分析、分析中 / 排队中 / 上次失败等状态;
142
- - 可勾选多个未分析分支批量开始,任务按队列依次执行;
143
- - 已分析分支可以直接「查看看板」或「重新分析」;
144
- - 全部分析完成后自动进入看板,也可以随时从看板右上角「分析分支」打开清单。
138
+ - 新建项目时填写本地目录绝对路径,DSH 插件模式下可以直接选择已登记的工作区;
139
+ - 创建后立即分析当前工作区,不克隆、不拉取、不切换分支;
140
+ - 可以随时重新分析,生成新的快照;
141
+ - 项目列表、任务与设置保存在全局插件目录,分析产出保存在项目自己的 `.arcvista/`。
145
142
 
146
143
  ### 1. 系统架构图(可下钻)
147
144
 
148
145
  ![节点详情](docs/screenshots/architecture-node.png)
149
146
 
150
- - 由仓库结构、构建清单、网关路由、Feign 声明、编排配置与依赖推断生成的分层架构图:客户端 → 接入层 → 服务层 → 数据层 → 基础设施 / 外部系统。
147
+ - 由项目结构、构建清单、网关路由、Feign 声明、编排配置与依赖推断生成的分层架构图:客户端 → 接入层 → 服务层 → 数据层 → 基础设施 / 外部系统。
151
148
  - 拖拽平移、滚轮缩放、节点悬停高亮依赖链路;点击节点打开节点详情(指标、复杂度驱动、上下游依赖、下钻入口)。
152
149
  - **下钻分析**:进入单个模块的内部结构(按 Controller / Service / Domain / DTO / 事件 / 配置等职责聚合的依赖子图)、关键流程、对外接口、调用方/被依赖方、数据组件、模块风险与改进建议;支持继续追问该模块。
153
150
  - 模型由 DSH 统一提供,架构的命名、业务职责、外部系统与连接会基于代码证据做语义增强;没有模型时启发式结果同样完整可用。
@@ -159,7 +156,7 @@ npm start
159
156
  - 19 类守护族:身份认证、授权/RBAC、输入校验、注入防护、传输加密、CORS、CSRF、限流、SAST、依赖扫描、密钥扫描、硬编码密钥、审计日志、依赖锁定、容器安全、安全响应头、安全测试等。
160
157
  - 每项守护给出 `enforced / partial / missing` 状态、规则清单、证据文件与行号、改进建议。
161
158
  - 解析 `semgrep`、ESLint 安全规则、CodeQL、SonarQube、gitleaks、Dependabot、Renovate、Snyk、Trivy、Dockerfile、lockfile、Spring Security / JWT / MyBatis / helmet / express-rate-limit 等真实配置。
162
- - 规则清单是**可 diff 的**:分支对比会逐条列出新增和移除的规则。
159
+ - 规则清单是**可 diff 的**:快照对比会逐条列出新增和移除的规则。
163
160
 
164
161
  ### 3. 业务知识图谱
165
162
 
@@ -182,9 +179,9 @@ npm start
182
179
  - 没有产物时使用明确标注的**估算值**(测试/源码 LOC 比、测试文件比、用例数、零测试模块),并在界面和风险条目中说明局限。
183
180
  - 展示测试用例数、测试文件、模块覆盖、未覆盖模块与大文件、测试缺口清单。
184
181
 
185
- ### 6. 分支对比与风险判断
182
+ ### 6. 快照对比与风险判断
186
183
 
187
- ![分支对比](docs/screenshots/branch-compare.png)
184
+ ![快照对比](docs/screenshots/branch-compare.png)
188
185
 
189
186
  选择任意两个快照(通常来自两个分支)进行对比:
190
187
 
@@ -207,43 +204,38 @@ npm start
207
204
  ## 工作方式
208
205
 
209
206
  ```
210
- Git 仓库 ──► 本地镜像(bare)──► 按提交检出 worktree
211
- │
212
- ┌─────────────────────────────┼──────────────────────────────┐
213
- ▼ ▼ ▼
214
- 文件/模块/依赖图 安全守护扫描 覆盖率产物解析
215
- │ │ │
216
- ├──────────────┬──────────────┴──────────────┬───────────────┤
217
- ▼ ▼ ▼ ▼
218
- 架构模型 知识图谱 复杂度 风险规则
219
- └──────────────┴──────────► 可选 LLM 语义增强 ◄─────────────┘
207
+ 本地项目目录(当前工作区)
208
+ │
209
+ ├── 文件 / 模块 / 依赖图 ──► 安全守护扫描 ──► 覆盖率产物解析
210
+ │ │
211
+ ├──────────────┬───────────────────┴──────────────┬───────────────┐
212
+ ▼ ▼ ▼ ▼
213
+ 架构模型 知识图谱 复杂度 风险规则
214
+ └──────────────┴──────────────► 可选 DSH 模型语义增强 ◄────────────┘
220
215
  │
221
216
  ▼
222
- 快照(SQLite)+ 分支 diff
217
+ <project>/.arcvista/snapshots/<id>.json
223
218
  ```
224
219
 
225
- - 仓库以 bare mirror 形式缓存在 `data/repos/`,按提交创建临时 worktree,分析完成后只保留最近用到的 worktree;**不会修改你的仓库**。
220
+ - ArcVista **不克隆、不拉取、不切换分支**;分析的是你选择的本地目录当前内容。
221
+ - 如果目录是 Git 仓库,会记录当前分支、提交与文件变更热度;非 Git 目录按“工作区”记录。
226
222
  - 每次分析生成一个不可变快照,包含完整分析 JSON、指标摘要、提交信息与引擎信息。
227
- - 分支对比基于快照计算,不依赖实时重分析。
228
-
229
- ## Git 超时与断点续传
230
-
231
- 大仓库首次克隆可能很慢。克隆、拉取与检出遇到**超时或网络错误会自动重试**(默认共 4 次,退避等待 2s / 4s / 6s):
232
-
233
- - 克隆中断时会保留已经下载的数据,下一次尝试自动改为 `git fetch` **断点续传**,而不是从头再克隆;
234
- - 记录每次重试的原因与次数,可在任务日志中看到("克隆失败(操作超时),2.0s 后自动重试(第 2/4 次)");
235
- - 网络长时间不可用导致最终失败时,任务卡片会提示可点「重试分析」继续续传,已下载的镜像不会丢弃;
236
- - 克隆、拉取、检出的超时时间与重试次数都可以用下方环境变量调整,例如大仓库可以调大 `ARCVISTA_GIT_CLONE_TIMEOUT_MS`。
223
+ - 快照对比基于保存的快照计算;重新分析当前工作区即可生成新快照。
237
224
 
238
225
  ## 数据、备份与隐私
239
226
 
240
- - DSH 插件模式下,所有数据保存在 `~/.dsh/arcvista/arcvista.db`(SQLite);独立 CLI 模式使用 `data/`。
241
- - 仓库镜像与检出代码在数据目录的 `repos/`、`worktrees/`,可随时删除,重新分析会重建。
242
- - **自动备份**:启动时、每次分析完成以及删除产品前,会把 SQLite 快照写入数据目录的 `backups/`(默认保留 12 份,文件权限 600;可用 `ARCVISTA_BACKUP_DIR` 修改位置,`ARCVISTA_BACKUP_KEEP` 修改保留数量)。
243
- - **自动恢复**:如果数据目录里的 `arcvista.db` 缺失或无法打开,启动时会自动从最近一份包含产品的备份恢复,并在启动日志和设置页提示恢复来源。
244
- - 设置页「当前状态」可以看到数据目录、备份目录和最近备份时间,也可以点「立即备份数据」手动备份。
245
- - 仓库镜像体积可能较大;备份只包含数据库(产品、快照、设置),不会重复备份仓库镜像。
246
- - 模型调用通过 DSH 的 `llm` 服务完成,ArcVista 不保存 API Key;分析时只把**仓库证据摘要**(模块、依赖、路由、配置、指标等,不含完整源码)发送给 DSH 当前模型。
227
+ - 全局项目列表、任务和设置保存在 `~/.dsh/arcvista/`:
228
+ - `projects.json`:项目列表与本地目录;
229
+ - `jobs.json`:分析任务与日志;
230
+ - `settings.json`:本地设置;
231
+ - `backups/`:全局 JSON 备份。
232
+ - 每个项目的分析产出保存在该项目的 `.arcvista/`:
233
+ - `snapshots/`:完整分析快照与索引;
234
+ - `drill/`:模块下钻缓存;
235
+ - `project.json`:ArcVista 项目标记。
236
+ - 这些文件只在本机,不会写进 `node_modules`,也不会被 ArcVista 的文件扫描计入分析。
237
+ - **备份**:每次分析完成、删除项目前以及手动点击「立即备份数据」时,会把全局项目列表、任务与设置写入 `backups/`(默认保留 12 份,权限 600)。
238
+ - 模型调用通过 DSH 的 `llm` 服务完成,ArcVista 不保存 API Key;分析时只把**项目证据摘要**(模块、依赖、路由、配置、指标等,不含完整源码)发送给 DSH 当前模型。
247
239
 
248
240
  ## 环境变量
249
241
 
@@ -254,12 +246,6 @@ Git 仓库 ──► 本地镜像(bare)──► 按提交检出 worktree
254
246
  | `ARCVISTA_DATA` | `./data` | 数据目录 |
255
247
  | `ARCVISTA_BACKUP_DIR` | `./backups`(data 的同级目录) | 数据库自动备份目录 |
256
248
  | `ARCVISTA_BACKUP_KEEP` | `12` | 备份保留份数 |
257
- | `ARCVISTA_NO_AUTO_RESTORE` | — | 设为 `1` 时禁止数据库缺失时自动恢复 |
258
- | `ARCVISTA_GIT_RETRIES` | `4` | 克隆/拉取/检出失败时的总尝试次数 |
259
- | `ARCVISTA_GIT_RETRY_DELAY_MS` | `2000` | Git 重试的基础退避时间(毫秒) |
260
- | `ARCVISTA_GIT_CLONE_TIMEOUT_MS` | `900000` | 单次克隆超时(默认 15 分钟) |
261
- | `ARCVISTA_GIT_FETCH_TIMEOUT_MS` | `300000` | 单次拉取超时(默认 5 分钟) |
262
- | `ARCVISTA_GIT_WORKTREE_TIMEOUT_MS` | `300000` | 单次检出超时(默认 5 分钟) |
263
249
  | `ARCVISTA_DEBUG` | — | 打印异常堆栈 |
264
250
 
265
251
  ## API 一览
@@ -267,12 +253,13 @@ Git 仓库 ──► 本地镜像(bare)──► 按提交检出 worktree
267
253
  | 方法 | 路径 | 说明 |
268
254
  | --- | --- | --- |
269
255
  | `GET` | `/api/products` | 产品列表(含最新快照与任务状态) |
270
- | `POST` | `/api/products` | 创建产品并自动开始分析 |
271
- | `GET` | `/api/products/:id` | 产品详情、快照、分支、任务 |
272
- | `POST` | `/api/products/:id/analyze` | 分析分支(`branch`、`force`) |
256
+ | `GET` | `/api/workspaces` | 可选的 DSH 工作区列表(插件模式) |
257
+ | `POST` | `/api/products` | 创建本地项目并分析当前工作区 |
258
+ | `GET` | `/api/products/:id` | 项目详情、快照、当前分支、任务 |
259
+ | `POST` | `/api/products/:id/analyze` | 重新分析当前工作区(`force`) |
273
260
  | `GET` | `/api/products/:id/snapshots/:sid` | 快照完整分析结果 |
274
261
  | `POST` | `/api/products/:id/modules/analyze` | 模块下钻(异步任务) |
275
- | `GET` | `/api/products/:id/compare` | 分支/快照对比 |
262
+ | `GET` | `/api/products/:id/compare` | 快照对比 |
276
263
  | `GET` | `/api/jobs/:id` | 任务进度与日志 |
277
264
 
278
265
  ## 目录结构
@@ -296,10 +283,10 @@ arcvista/
296
283
  │ │ ├── drilldown.js # 模块下钻
297
284
  │ │ └── index.js # 分析编排
298
285
  │ ├── diff.js # 快照对比与风险规则
299
- │ ├── git.js # mirror / worktree / 分支 / churn
300
- │ ├── db.js # SQLite
286
+ │ ├── local-git.js # 本地目录 Git 元数据 / churn
287
+ │ ├── store.js # 全局 JSON + 项目 .arcvista 存储
301
288
  │ ├── jobs.js # 后台任务
302
- │ ├── llm.js # OpenAI 兼容客户端
289
+ │ ├── llm.js # OpenAI 兼容客户端(独立模式)
303
290
  │ └── http.js # 极小 HTTP 框架
304
291
  ├── public/ # 零构建前端(原生 ES Modules + SVG)
305
292
  │ ├── css/
@@ -322,17 +309,17 @@ npm run dev # node --watch 热重启
322
309
 
323
310
  ## 已知边界
324
311
 
325
- - 覆盖率优先使用仓库中的真实产物;没有产物时是**估算值**,只能用于判断覆盖明显偏低或缺失,不能当作真实覆盖率。
312
+ - 覆盖率优先使用项目中的真实产物;没有产物时是**估算值**,只能用于判断覆盖明显偏低或缺失,不能当作真实覆盖率。
326
313
  - 安全守护与风险模式是启发式识别,结论会附证据路径,但仍建议人工复核。
327
314
  - 架构中的服务间调用来自网关路由、Feign 声明、导入关系与配置推断;运行期的动态调用(如反射、消息路由)可能无法完全还原。
328
- - 仓库扫描有文件数与单文件大小上限(默认 2 万文件 / 1.2MB),超大 monorepo 可调整 `scanRepo` 参数。
329
- - 私有仓库需要本机 git 凭据(SSH key 或 credential helper)可用。
330
- - 分支对比的语义依赖守护规则 ID、模块路径、API 路径的稳定性;大范围重命名会被视为删除 + 新增。
315
+ - 项目扫描有文件数与单文件大小上限(默认 2 万文件 / 1.2MB),超大 monorepo 可调整 `scanRepo` 参数。
316
+ - 非 Git 目录同样可以分析;Git 目录只需要本机可执行的 `git`,不需要网络。
317
+ - 快照对比的语义依赖守护规则 ID、模块路径、API 路径的稳定性;大范围重命名会被视为删除 + 新增。
331
318
  - LLM 增强需要网络与模型费用;失败会自动回退到启发式分析并在快照中记录提示。
332
319
 
333
320
  ## 与 Archify 的关系
334
321
 
335
- 架构图能力借鉴了 [Archify](../../.agents/skills/archify) 的分层表达与交互思路(分层、边界、图例、下钻),但 ArcVista 把它放进了持续观测的产品看板:仓库即数据源、快照可对比、结论可追溯,并增加了安全守护、知识图谱、复杂度、覆盖率与分支风险判断。
322
+ 架构图能力借鉴了 [Archify](../../.agents/skills/archify) 的分层表达与交互思路(分层、边界、图例、下钻),但 ArcVista 把它放进了持续观测的产品看板:本地目录即数据源、快照可对比、结论可追溯,并增加了安全守护、知识图谱、复杂度、覆盖率与分支风险判断。
336
323
 
337
324
 
338
325
  ## License
package/index.js CHANGED
@@ -66,11 +66,26 @@ export function apply(ctx, config = {}) {
66
66
  path.join(os.homedir(), '.dsh', 'arcvista'),
67
67
  );
68
68
  const backupDir = path.resolve(config.backupDir || process.env.ARCVISTA_BACKUP_DIR || path.join(dataDir, 'backups'));
69
+ const listWorkspaces = () => {
70
+ try {
71
+ const registry = typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : undefined;
72
+ if (!registry?.list) return [];
73
+ return registry.list().map((workspace) => ({
74
+ id: String(workspace.id),
75
+ title: String(workspace.title || ''),
76
+ path: String(workspace.path || ''),
77
+ createdAt: workspace.createdAt || null,
78
+ }));
79
+ } catch {
80
+ return [];
81
+ }
82
+ };
69
83
  const app = createArcVistaApp({
70
84
  dataDir,
71
85
  publicDir: path.join(APP_ROOT, 'public'),
72
86
  backupDir,
73
87
  llm: createDshLlm(ctx),
88
+ workspaces: listWorkspaces,
74
89
  });
75
90
 
76
91
  const handler = async (req, res) => {
@@ -1,4 +1,6 @@
1
+ import fs from 'node:fs';
1
2
  import { scanRepo } from './walk.js';
3
+ import { inspectProject, fileChurn } from '../local-git.js';
2
4
  import { detectModules } from './modules.js';
3
5
  import { buildGraph } from './graph.js';
4
6
  import { analyzeDeps } from './deps.js';
@@ -84,77 +86,27 @@ function buildSummary({ analysis, product }) {
84
86
  }
85
87
 
86
88
  /**
87
- * Clone/fetch the repository mirror and list remote branches WITHOUT running
88
- * the expensive analysis. The UI uses this as the first step after adding a repo,
89
- * so the user can see progress and then choose which branches to analyse.
89
+ * Analyse the current working tree of a local project directory. ArcVista does
90
+ * not clone, fetch or check out anything: the user's checkout is the source of
91
+ * truth, and all analysis artifacts are written to <project>/.arcvista.
90
92
  */
91
- export async function prepareRepository({ store, git, product, log, progress }) {
92
- const startedAt = Date.now();
93
- progress(3, '准备仓库');
94
- log(`准备仓库 ${product.repoUrl}`);
95
- let mirrorHigh = 0;
96
- const { defaultBranch } = await git.ensureMirror(
97
- product,
98
- (message, level) => log(message, level),
99
- (info) => {
100
- const percent = Number(info?.percent) || 0;
101
- mirrorHigh = Math.max(mirrorHigh, percent);
102
- progress(5 + Math.round(mirrorHigh * 0.6), `拉取仓库 · ${info.detail || `${percent}%`}`);
103
- },
104
- );
105
- if (defaultBranch && defaultBranch !== product.defaultBranch) {
106
- store.products.update(product.id, { defaultBranch });
107
- }
108
- progress(70, '读取分支列表');
109
- const branches = await git.listBranches(product.id).catch(() => []);
110
- log(`仓库已就绪,检测到 ${branches.length} 个分支`);
111
- progress(100, '仓库已就绪');
112
- return {
113
- defaultBranch: defaultBranch || product.defaultBranch,
114
- branchCount: branches.length,
115
- branches: branches.slice(0, 120).map((branch) => ({
116
- name: branch.name,
117
- short: branch.short,
118
- date: branch.date,
119
- subject: branch.subject,
120
- })),
121
- durationMs: Date.now() - startedAt,
122
- };
123
- }
124
-
125
- /**
126
- * Full repository analysis: mirrors the repo, checks out one commit, runs every
127
- * collector, optionally enriches with the LLM, then persists a snapshot.
128
- */
129
- export async function analyzeRepository({ store, git, product, branch, force = false, llm, log, progress }) {
93
+ export async function analyzeRepository({ store, product, force = false, llm, log, progress }) {
130
94
  const startedAt = Date.now();
131
95
  const warnings = [];
132
-
133
- progress(3, '准备仓库镜像');
134
- log(`准备分析 ${product.name}`);
135
- let mirrorHigh = 0;
136
- const { defaultBranch } = await git.ensureMirror(
137
- product,
138
- (message, level) => log(message, level),
139
- (info) => {
140
- const percent = Number(info?.percent) || 0;
141
- mirrorHigh = Math.max(mirrorHigh, percent);
142
- progress(3 + Math.round(mirrorHigh * 0.05), `准备仓库 · ${info.detail || `${percent}%`}`);
143
- },
144
- );
145
- const targetBranch = branch || defaultBranch || product.defaultBranch || 'main';
146
- if (defaultBranch && defaultBranch !== product.defaultBranch) {
147
- store.products.update(product.id, { defaultBranch });
96
+ const projectPath = String(product.path || product.repoUrl || '').trim();
97
+ if (!projectPath || !fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
98
+ throw new Error(`本地项目目录不存在或不可读:${projectPath || '(空)'}`);
148
99
  }
149
- log(`目标分支 ${targetBranch}`);
150
100
 
151
- progress(8, '解析提交');
152
- const sha = await git.resolveRef(product.id, targetBranch);
153
- const commit = await git.commitInfo(product.id, sha);
154
- log(`提交 ${commit.short} · ${commit.subject}`);
101
+ progress(3, '读取本地项目');
102
+ log(`分析本地目录 ${projectPath}`);
103
+ const info = await inspectProject(projectPath);
104
+ const targetBranch = info.branch || '工作区';
105
+ const commit = info.commit;
106
+ log(`${info.isGit ? `当前分支 ${targetBranch}` : '非 Git 目录,按当前文件内容分析'} · ${commit.short || 'local'}`);
155
107
 
156
- if (!force) {
157
- const existing = store.snapshots.findByCommit(product.id, sha);
108
+ if (!force && !info.dirty && commit.sha) {
109
+ const existing = store.snapshots.findByCommit(product.id, commit.sha);
158
110
  if (existing) {
159
111
  log(`该提交已有分析快照(${existing.id}),直接复用`);
160
112
  progress(100, '已存在快照');
@@ -162,12 +114,8 @@ export async function analyzeRepository({ store, git, product, branch, force = f
162
114
  }
163
115
  }
164
116
 
165
- progress(13, '检出代码');
166
- const worktree = await git.ensureWorktree(product.id, sha, (message) => log(message));
167
- await git.pruneWorktrees(product.id, [sha]).catch(() => {});
168
-
169
- progress(20, '扫描仓库文件');
170
- const repo = await scanRepo(worktree);
117
+ progress(20, '扫描本地文件');
118
+ const repo = await scanRepo(projectPath);
171
119
  log(`扫描到 ${repo.totals.files} 个文件 / ${repo.totals.sourceFiles} 个源文件 / ${repo.totals.sourceLoc} 行代码`);
172
120
  if (repo.truncated) {
173
121
  warnings.push('仓库文件数量超过扫描上限,部分文件被忽略');
@@ -192,7 +140,7 @@ export async function analyzeRepository({ store, git, product, branch, force = f
192
140
  log(coverage.measurable ? `解析到真实覆盖率:${coverage.line}%` : `未发现覆盖率产物,估算覆盖 ${coverage.line}%`);
193
141
 
194
142
  progress(52, '度量系统复杂度');
195
- const churn = await git.fileChurn(product.id, sha).catch(() => ({ commits: 0, fileCommits: new Map() }));
143
+ const churn = await fileChurn(projectPath, commit.sha).catch(() => ({ commits: 0, fileCommits: new Map() }));
196
144
  const complexity = analyzeComplexity(repo, {
197
145
  modulesInfo,
198
146
  graph,
@@ -291,7 +239,8 @@ export async function analyzeRepository({ store, git, product, branch, force = f
291
239
  version: 1,
292
240
  meta: {
293
241
  productName: product.name,
294
- repoUrl: product.repoUrl,
242
+ repoUrl: projectPath,
243
+ projectPath,
295
244
  branch: targetBranch,
296
245
  commit,
297
246
  analyzedAt,
@@ -341,7 +290,7 @@ export async function analyzeRepository({ store, git, product, branch, force = f
341
290
  progress(94, '保存快照');
342
291
  const summary = buildSummary({ analysis, product });
343
292
  const snapshotId = store.snapshots.insert({
344
- id: force ? store.snapshots.findByCommit(product.id, sha)?.id : undefined,
293
+ id: force ? store.snapshots.findByCommit(product.id, commit.sha)?.id : undefined,
345
294
  productId: product.id,
346
295
  branch: targetBranch,
347
296
  commit,
@@ -357,8 +306,6 @@ export async function analyzeRepository({ store, git, product, branch, force = f
357
306
  } catch {
358
307
  /* backups must never break an analysis */
359
308
  }
360
- await git.pruneWorktrees(product.id, [sha]).catch(() => {});
361
-
362
309
  log(`快照已保存,用时 ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
363
310
  progress(100, '分析完成');
364
311
  return {
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
 
5
5
  /** Directories that never carry product source and would drown the scan. */
6
6
  export const EXCLUDED_DIRS = new Set([
7
- 'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', 'target',
7
+ 'node_modules', '.git', '.arcvista', '.hg', '.svn', 'dist', 'build', 'out', 'target',
8
8
  '.next', '.nuxt', '.output', '.svelte-kit', '.turbo', '.parcel-cache',
9
9
  '.m2repo', '.m2', '.gradle', '.idea', '.vscode', 'vendor', 'Pods',
10
10
  '.venv', 'venv', 'env', '__pycache__', '.pytest_cache', '.tox', '.eggs',
package/lib/jobs.js CHANGED
@@ -1,10 +1,10 @@
1
- import { newId, nowIso } from './db.js';
1
+ import { newId, nowIso } from './store.js';
2
2
 
3
3
  const MAX_LOGS = 220;
4
4
  const FINISHED_RETENTION_MS = 10 * 60 * 1000;
5
5
 
6
6
  /**
7
- * In-process job queue persisted to SQLite.
7
+ * In-process job queue persisted to the global JSON store.
8
8
  * Heavy git/LLM work runs one job per product at a time; extra submissions are
9
9
  * queued and started in order, so "分析多个分支" can be submitted at once.
10
10
  */
@@ -0,0 +1,121 @@
1
+ /** Local-directory Git metadata. ArcVista never clones or checks out a project. */
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import crypto from 'node:crypto';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const TIMEOUT = Number(process.env.ARCVISTA_GIT_TIMEOUT_MS || 20000);
8
+ const ENV = {
9
+ ...process.env,
10
+ GIT_TERMINAL_PROMPT: '0',
11
+ GIT_OPTIONAL_LOCKS: '0',
12
+ };
13
+
14
+ async function git(projectPath, args, options = {}) {
15
+ const { stdout } = await execFileAsync('git', ['-C', projectPath, ...args], {
16
+ env: ENV,
17
+ timeout: options.timeout || TIMEOUT,
18
+ maxBuffer: options.maxBuffer || 32 * 1024 * 1024,
19
+ });
20
+ return String(stdout || '').trim();
21
+ }
22
+
23
+ export function localCommit(projectPath) {
24
+ const sha = `local-${crypto.createHash('sha1').update(projectPath).digest('hex').slice(0, 12)}`;
25
+ return {
26
+ sha,
27
+ short: 'local',
28
+ subject: '本地工作区',
29
+ author: '',
30
+ date: new Date().toISOString(),
31
+ };
32
+ }
33
+
34
+ export async function isGitRepository(projectPath) {
35
+ try {
36
+ return (await git(projectPath, ['rev-parse', '--is-inside-work-tree'])) === 'true';
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ export async function inspectProject(projectPath) {
43
+ try {
44
+ const inside = await git(projectPath, ['rev-parse', '--is-inside-work-tree']);
45
+ if (inside !== 'true') throw new Error('not a work tree');
46
+ let branch = '';
47
+ try {
48
+ branch = await git(projectPath, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
49
+ } catch {
50
+ branch = (await git(projectPath, ['rev-parse', '--short', 'HEAD'])).trim();
51
+ }
52
+ const format = '%H%x00%h%x00%s%x00%an%x00%aI';
53
+ const text = await git(projectPath, ['show', '-s', `--format=${format}`, 'HEAD']);
54
+ const [sha, short, subject, author, date] = text.split('\u0000');
55
+ const status = await git(projectPath, ['status', '--porcelain', '--untracked-files=normal']);
56
+ return {
57
+ isGit: true,
58
+ branch: branch || 'HEAD',
59
+ commit: { sha, short, subject: subject || '', author: author || '', date: date || '' },
60
+ dirty: status.length > 0,
61
+ status,
62
+ };
63
+ } catch {
64
+ return {
65
+ isGit: false,
66
+ branch: '工作区',
67
+ commit: localCommit(projectPath),
68
+ dirty: true,
69
+ status: '',
70
+ };
71
+ }
72
+ }
73
+
74
+ /** The local workflow analyses exactly one checked-out state. */
75
+ export async function listBranches(projectPath) {
76
+ const info = await inspectProject(projectPath);
77
+ return [
78
+ {
79
+ name: info.branch,
80
+ short: info.commit.short,
81
+ date: info.commit.date,
82
+ subject: info.commit.subject,
83
+ current: true,
84
+ isGit: info.isGit,
85
+ },
86
+ ];
87
+ }
88
+
89
+ export async function fileChurn(projectPath, sha, { days = 180, maxCommits = 400 } = {}) {
90
+ try {
91
+ if (!sha || String(sha).startsWith('local-')) return { commits: 0, fileCommits: new Map() };
92
+ const out = await git(
93
+ projectPath,
94
+ [
95
+ 'log',
96
+ `--since=${days} days ago`,
97
+ '--no-merges',
98
+ `-n ${maxCommits}`,
99
+ '--format=commit:%H',
100
+ '--name-only',
101
+ sha,
102
+ '--',
103
+ ],
104
+ { timeout: 45000, maxBuffer: 48 * 1024 * 1024 },
105
+ );
106
+ const fileCommits = new Map();
107
+ let commits = 0;
108
+ for (const line of out.split('\n')) {
109
+ if (line.startsWith('commit:')) {
110
+ commits += 1;
111
+ continue;
112
+ }
113
+ const file = line.trim();
114
+ if (!file) continue;
115
+ fileCommits.set(file, (fileCommits.get(file) || 0) + 1);
116
+ }
117
+ return { commits, fileCommits };
118
+ } catch {
119
+ return { commits: 0, fileCommits: new Map() };
120
+ }
121
+ }