@perrylink/dsh-plugin-doctor 0.1.7 → 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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - **Silent-pass fixes (the load-bearing change)** — three ways this tool could return a green result without having actually decided anything are now closed:
6
+ - The K group's source-file discovery required `!pkg.scripts.build` before falling back to `lib/**`. Published packages keep their `scripts`, so an installed-package directory or an unpacked tarball produced **zero files inspected → K1–K9 all skipped → exit 0**. The fallback no longer checks for a build script, and `main` is now `./`-stripped (family repos with `main: "./index.mjs"` never triggered the old fallback).
7
+ - `R5` returned `pass` when there was no `src/` to scan ("a pure-JS repo may have no cordis runtime dependency"). It now returns `skip` — and it is one of the 16 gated checks.
8
+ - A requested group whose checks all skipped is reported as **degraded** and exits **6** (use `--allow-degraded` to accept it explicitly). Previously only "zero checks executed" was caught.
9
+ - **New exit codes**: `3` infrastructure, `4` unsupported host (a host install failure is no longer reported as a plugin defect), `5` unstable, `6` degraded. `0/1/2` are unchanged, and the family gate never reads the exit code, so nothing downstream changes.
10
+ - **New options**: `--workspace/-w`, `--allow-degraded`, `--json -`, `--purge`, `-v/--version`. **Unknown options are now a usage error (exit 2)** — previously an unrecognised long option had its value assigned to `--repo`, silently retargeting the check.
11
+ - **JSON envelope v2** (`schemaVersion: "2"`): adds `doctorVersion`, `checksetVersion`, `target`, `env`, `coverage`, `groups`, `degraded`, `verdict`, `quarantine`, and per-result `id`/`groupId`/`category`. **`name` keeps its `R0 `/`K1 ` prefix — that prefix is a frozen contract** (the family workflow greps for `R0 ` and `K1 ` on the rendered output and splits `results[].name` on `/^R[24] /`).
12
+ - **Sandbox discipline**: `dsh plugin add` now passes `--ignore-scripts` (the target's install/prepare scripts never run on the host); pnpm's ignored-builds block is classified `environment` instead of being counted as a plugin result; `npm pack` writes into the sandbox (8 family repos had accumulated 17 stray `.tgz` files); the sandbox prefix is now `doctor-` so it no longer collides with the host's protected `%TEMP%\dsh-*` template; the sandbox is **quarantined, never deleted** (three-stage delete discipline) with `--purge` as the explicit third stage.
13
+ - **Report hygiene**: run-time absolute paths are replaced with `<path>` in both the JSON and the rendered text, so a report can be committed into someone else's repository without tripping their path-leak gate.
14
+ - **CC group semantics**: `CC5`'s three gates (Apache-2.0 / five-language READMEs / seam markers) are PerryLink-family standards, not ecosystem standards. Outside a family workspace it now reports `not-applicable` instead of failing any third-party repository (and instead of letting the whole CC group look like an environment failure).
15
+ - **Regression proof**: a 37-repo before/after baseline over the whole family (`--only R,K --no-smoke`) shows **zero item-level changes**; `tests/contract.mjs` freezes the five observables the family CI depends on; `tests/selftest.mjs` grows from 7 to 14 cases with the original 7 unchanged.
16
+ - **Release-path hardening (this repository's own CI)** — the same silent-pass class, closed one level up: `ci.yml` now runs `tests/contract.mjs` on every push instead of only inside the publish workflow, where a break would already have been shipped; and `publish.yml` no longer exits `0` when `NPM_TOKEN` is absent — a release that published nothing used to look green. A post-publish step now asserts the version is actually visible on the registry before the GitHub Release is created. (This supersedes the "skips cleanly when `NPM_TOKEN` is absent" wording in 0.1.5.)
17
+ - **Public result set**: `THIRD-PARTY-RK-SCAN.md` + `data/rk-scans.json` — the first static R+K scan of **third-party** (non-PerryLink) DSH plugins: 60 candidates → 20 that actually declare `dsh.bundle.patch` → **10 passing / 10 failing** under the 16 gated checks, with the method, this scan's own methodology error, and a corrections path for named maintainers. No third-party code was executed.
18
+
3
19
  ## 0.1.7
4
20
 
5
21
  - **Gate form migration (family CI)**: the 36 plugin repos' `plugin-doctor.yml` no longer carries the `\u`-escaped `DOCTOR_ONLY` environment variable; the gate now runs `npx --yes @perrylink/dsh-plugin-doctor@0.1.6 --repo . --no-smoke --only "R,K"` directly, so the whole step is plain ASCII and self-explanatory. The R0/K1 self-check and the JSON gate (R2/R4 reported but gated by each repo's `ci.yml`) are unchanged. Rolled out canary-first (`gated 16 checks; build-dependent: R2=pass R4=pass`), then all 36 repos: 36/36 green, registry `gate=ascii-alias + self-check`, `doctorPinned=0.1.6`.
package/README.md CHANGED
@@ -20,8 +20,24 @@ node doctor.mjs --repo <路径> --no-smoke # 仅静态 + 清单
20
20
  node doctor.mjs --repo <路径> --dsh 0.1.2-rc.1 # 冒烟宿主版本(默认 npm latest 已发布线)
21
21
  node doctor.mjs --repo <路径> --only R,K # 只跑静态两层(推荐用 ASCII 别名)
22
22
  node doctor.mjs --repo <路径> --json report.json
23
+ node doctor.mjs --repo <路径> --json - # JSON 写 stdout(此时抑制人类可读报告)
24
+ node doctor.mjs --repo <路径> --workspace <工作区根> # 指定兄弟仓所在工作区(CC 组核对用)
25
+ node doctor.mjs --repo <路径> --allow-degraded # 显式接受「整组未真跑」(默认 exit 6)
26
+ node doctor.mjs --purge <隔离目录> # 清理本工具产生的隔离目录(仅 doctor-quarantine-*)
23
27
  ```
24
28
 
29
+ ### 目标形态与覆盖率(0.2.0 新增)
30
+
31
+ `--repo` 可以是**源码树**,也可以是**已装包目录 / 解包后的 tarball 产物**(后者常见于 `node_modules/<pkg>`)。判据随形态变化:
32
+
33
+ | 形态 | K 组(cordis 契约) | 说明 |
34
+ |---|---|---|
35
+ | 有 `src/` 的源码树 | 扫 `src/**` + 根层 JS(`mode: src`) | 完整 |
36
+ | **无 `src/`、`main` 指向 `lib/`** | **兜底扫 `lib/**`(`mode: lib-fallback`)** | 0.2.0 修复:旧实现的门槛是"无 build 脚本",而已发布包**保留** build 脚本 → 兜底永不触发、K 九项全 skip 却仍 exit 0(假绿) |
37
+ | 既无 `src/` 也无 `lib/` | 九项全 skip(`mode: none`) | **整组未真跑 → 退出码 6**,不再假绿 |
38
+
39
+ 覆盖率写入 JSON 的 `coverage.K`(`{filesInspected, mode}`),并汇总到 `groups.K`。
40
+
25
41
  ### `--only` 分组与 ASCII 别名
26
42
 
27
43
  | 别名 | 分组全名 | 内容 |
@@ -35,16 +51,37 @@ node doctor.mjs --repo <路径> --json report.json
35
51
 
36
52
  ### 退出码契约
37
53
 
38
- | 码 | 含义 |
39
- |---|---|
40
- | `0` | 无 fail/error(可含 warn/skip |
41
- | `1` | 存在 fail/error |
42
- | `2` | 用法错误、未知分组、或**零检查执行** |
54
+ | 码 | 含义 | 引入版本 |
55
+ |---|---|---|
56
+ | `0` | 无 fail/error(可含 warn/skip),且被请求的分组都真的跑了 | 0.1.x |
57
+ | `1` | 存在 fail/error(插件缺陷) | 0.1.x |
58
+ | `2` | 用法错误、未知分组、**未知选项** | 0.1.x |
59
+ | `3` | 基础设施错误(缺 npm/pnpm 等环境不可用) | **0.2.0** |
60
+ | `4` | 不支持的宿主版本(宿主自身安装失败,**不判插件**) | **0.2.0** |
61
+ | `5` | 结果不稳定(步骤超时/被信号终止) | **0.2.0** |
62
+ | `6` | **降级**:被请求的分组整组未真跑(如无源文件可扫描) | **0.2.0** |
63
+
64
+ **防静默通过**(两层):
65
+
66
+ 1. `--only` 里只要有一个分组名不匹配 → 立即 `2`。0.1.4 及更早版本在分组名乱码时会"零检查 + exit 0",这曾让 35 个仓的 CI 门禁变成假绿(2026-09-09 实测:`checks_run=0`、`exit=0`)。
67
+ 2. 0.2.0 起:**被请求的分组若整组未真跑(全 skip)→ `6`**。旧实现只覆盖"一项都没跑",不覆盖"跑了但全 skip" —— 后者会让"K 组 0 覆盖"被当成通过。如需显式接受,用 `--allow-degraded`(退出码降为 0,但 JSON 里 `degraded` 仍非空)。
43
68
 
44
- **防静默通过**:`--only` 里只要有一个分组名不匹配,或最终零检查执行,本工具立即以 `2` 失败。0.1.4 及更早版本在分组名乱码时会"零检查 + exit 0",这曾让 35 个仓的 CI 门禁变成假绿(2026-09-09 实测:`checks_run=0`、`exit=0`)。
69
+ > ⚠️ 既有家族 37 仓的门禁**不读退出码**(workflow 用 `set +e` / `out="$(…)"` / `set -e`),只读 stdout `R0 ` / `K1 ` 与 JSON 里 `results[].name` 前缀分流。因此 0.2.0 的退出码新增**对既有链路零影响**;它服务于交互式使用与未来的接入方。
45
70
 
46
- - 冒烟全程使用 `%TEMP%` mkdtemp 临时 `DSH_HOME`/`DSH_AGENTS_HOME`,绝不触碰真实 `~/.dsh`(红线 3)。
47
- - 每步子进程 stdout/stderr 落盘 `%TEMP%\dsh-doctor-logs-*`,报告尾部打印路径,证据可查。
71
+ - 冒烟全程使用 `%TEMP%` 自建沙箱(前缀 `doctor-`,**不与宿主保护模板 `%TEMP%\dsh-*` 重叠**)承载临时 `DSH_HOME`/`DSH_AGENTS_HOME`,绝不触碰真实 `~/.dsh`(红线 3)。
72
+ - `dsh plugin add` 显式带 `--ignore-scripts`:被测包的 install/prepare 脚本不在宿主执行。pnpm 的 ignored-builds 阻断归类为 `environment`(不计 pass、不计插件缺陷)。
73
+ - 每步子进程 stdout/stderr 落盘 `%TEMP%\doctor-run-*\logs\`;运行结束**只隔离不删除**(rename 到 `%TEMP%\doctor-quarantine-*`),报告尾部打印该路径,人工确认后用 `--purge` 清理(红线 4 三段式)。
74
+ - 运行期绝对路径在写入 JSON 与渲染文本前统一占位化为 `<path>`,便于把报告提交进别人的仓而不触发其路径泄漏门禁。
75
+
76
+ ## 同名区分(重要)
77
+
78
+ 本仓是 **`@perrylink/dsh-plugin-doctor`**,与生态里其他同名工具**不是同一个项目**:
79
+
80
+ - npm 裸名 `dsh-plugin-doctor` 属 **Xrainsmile/DSH-Plugin-Doctor**(另一个项目,0.1.1)。因此**永远不要用 `npx dsh-plugin-doctor`** —— 那会执行别人的包;请始终用 scoped 全名 `@perrylink/dsh-plugin-doctor@<精确版本>`。
81
+ - GitHub 上名称含 `dsh-plugin-doctor` 的仓有 10 个(其中**恰同名者 8 个**),包括 `zoahdev/dsh-plugin-doctor`(GitHub-only,未发布到 npm)。
82
+ - `dsh-testkit` 的 README 把 `dsh-plugin-doctor` 链向 zoahdev 的仓,与本仓无关。
83
+
84
+ 一句话定位:**零依赖、可离线(`--only R,K`)、把 cordis v4 契约(K1–K9)与五大集合站清单(CC1–CC5)做成退出码可判读的 CI 门禁**。("唯一"这类全称不作声称——仅在已核对的工具集合内未见同类。)
48
85
 
49
86
  ## 检测目录
50
87
 
@@ -67,7 +104,7 @@ node doctor.mjs --repo <路径> --json report.json
67
104
  - 徽章外观:视觉语言对齐生态里较新的两枚徽章(`dsh.directory` 的等宽大写 + 字距 + 标记 + 渐变,`awesome-dsh-plugin` 的印章块)——**银白/铂金金属左段 + 盾牌勾标记 + 墨蓝等宽大写字**(金行主导、水行在字),右段是**整块 GitHub 惯例状态色**(绿/橙/红/灰)配等宽大写状态词,状态另用**路径绘制的图标**(✓ / ! / ✕ / –)冗余表达,色觉障碍下同样可读。5px 圆角 + 1px 描边;**描边是必需的**——去掉后银白左段在白色 README 背景上会消失。
68
105
  - 四种状态(值文本用 shields / GitHub Actions 惯用词):`passing`(绿,HEAD 上 run success)/ `warning`(橙:HEAD 还没跑、run 仍在队列、或缺少门禁配置的前置条件)/ `failing`(红:HEAD 上 run 失败,或门禁配置不成立——含 `--only` 参数是双重编码乱码的"假门禁")/ `no data`(灰:API 查询失败)。徽章是**动态**的:不再通过就会变红。R+K 的精确口径不在徽章文字里,而在本节与注册表 `meaning` 字段(徽章链接指回本节)。
69
106
  - 加入方式:向 `data/verified-repos.json` 提 PR 增加 `{ "repo": "<owner>/<name>", "package": "<npm 包名>" }`,并按下面的门禁在自己的仓里加 `plugin-doctor.yml`;条目必须通过上面的门禁核对。
70
- - 门禁步骤(完整工作流见任一家族仓的 `.github/workflows/plugin-doctor.yml`;分组名用 **ASCII 别名 `R,K`**——0.1.5 起支持,文件与命令行全程纯 ASCII;末尾自校验 R0/K1 确实跑了。家族 36 仓当前 pin `0.1.6`):
107
+ - 门禁步骤(完整工作流见任一家族仓的 `.github/workflows/plugin-doctor.yml`;分组名用 **ASCII 别名 `R,K`**——0.1.5 起支持,文件与命令行全程纯 ASCII;末尾自校验 R0/K1 确实跑了。**家族 37 仓当前 pin `0.1.6`**):
71
108
 
72
109
  ```yaml
73
110
  - name: Run dsh-plugin-doctor (static R/K on the committed tree)
@@ -121,22 +158,33 @@ lib/checks-package.mjs 静态·包结构 R0–R8
121
158
  lib/checks-cordis.mjs 静态·cordis 契约 K1–K9
122
159
  lib/checks-smoke.mjs 动态·沙箱冒烟 D0–D3、D9
123
160
  lib/checks-collections.mjs 生态·集合站清单 CC1–CC5
124
- tests/selftest.mjs 7 例真实 CLI 自检(退出码契约 + 防静默通过回归守卫)
161
+ tests/selftest.mjs 14 例真实 CLI 自检(既有 7 例退出码契约逐字不变 + 新增降级/用法守卫 7 例)
162
+ tests/contract.mjs 31 项契约测试(冻结既有 37 仓 CI 依赖的 5 个可观测量)
125
163
  scripts/verify.mjs verified 注册表与徽章刷新(只读 GitHub API 审计各仓门禁)
126
164
  scripts/badge.mjs verified SVG 渲染
127
165
  data/verified-repos.json verified 声明仓清单
128
166
  data/verified.json verified 注册表(CI 生成)
129
167
  badges/ verified 徽章(CI 生成)
168
+ THIRD-PARTY-RK-SCAN.md 第三方插件静态 R+K 扫描结果集(公开报告)
169
+ data/rk-scans.json 上述扫描的机器可读形态
130
170
  SURVEY.md 全渠道检测方法梳理 + 判据出处
131
171
  ```
132
172
 
133
173
  ## 状态
134
174
 
135
- 正式仓库:GitHub `PerryLink/dsh-plugin-doctor`(Apache-2.0),npm `@perrylink/dsh-plugin-doctor`
136
- (**latest=0.1.7**,见 `CHANGELOG.md`)。CI 用法(**请用 ASCII 别名**):
175
+ 正式仓库:GitHub `PerryLink/dsh-plugin-doctor`(Apache-2.0),npm `@perrylink/dsh-plugin-doctor`。
176
+ **当前版本 0.2.0**(npm 上 0.2.0 之前的最新为 0.1.7),见 `CHANGELOG.md`。CI 用法(**请用 ASCII 别名**):
137
177
 
138
178
  ```powershell
139
- npx --yes @perrylink/dsh-plugin-doctor@0.1.7 --repo . --no-smoke --only "R,K"
179
+ npx --yes @perrylink/dsh-plugin-doctor@0.2.0 --repo . --no-smoke --only "R,K"
140
180
  ```
141
181
 
142
- 36 个插件仓已内置 `.github/workflows/plugin-doctor.yml`(只读已提交树 → `--only "R,K"` 静态门禁 + R0/K1 实跑自校验,pin `@0.1.6`)。
182
+ **37 个插件仓**已内置 `.github/workflows/plugin-doctor.yml`(只读已提交树 → `--only "R,K"` 静态门禁 + R0/K1 实跑自校验,pin `@0.1.6`)。
183
+ pin 停在 0.1.6 是有意的:0.2.0 对 R/K 两组的判据与输出形态**逐字不变**(`tests/contract.mjs` 已把这条冻成断言),所以提升 pin 是一波独立动作,不是本次发布的前置条件。
184
+
185
+ > 0.2.0 的改动全部是**加法式**(新增字段 / 新增选项 / 新增退出码),既有 37 仓的判据不变;已用 37 仓基线逐项比对验证 **diffs = 0**。
186
+
187
+ ### 公开结果集
188
+
189
+ - [`THIRD-PARTY-RK-SCAN.md`](https://github.com/PerryLink/dsh-plugin-doctor/blob/main/THIRD-PARTY-RK-SCAN.md) —— 首份**第三方**(非 PerryLink)dsh 插件的静态 R+K 扫描:60 个候选 → 20 个真正声明 `dsh.bundle.patch` 的插件 → 16 项门禁下 **10 通过 / 10 失败**。方法:只读克隆、**零执行**第三方代码、R2/R4 单列不入门禁;含复现命令、本次扫描自身的方法学更正,以及**被点名仓的更正通道**。机器可读形态:`data/rk-scans.json`。
190
+ **它不是认证、不是评级,也不代表插件安全**:pass 仅表示「该 commit 上 16 项静态门禁未报失败」。
package/doctor.mjs CHANGED
@@ -1,38 +1,60 @@
1
1
  #!/usr/bin/env node
2
2
  // dsh-plugin-doctor:dsh 插件「完整性 + 运行流畅」一体检测器
3
- // 分组:静态·包结构 / 静态·cordis 契约扫描 / 动态·沙箱冒烟 / 生态·集合站清单
3
+ // 分组:静态·包结构(R) / 静态·cordis 契约扫描(K) / 动态·沙箱冒烟(D) / 生态·集合站清单(CC)
4
+ //
5
+ // 不可变契约(R-fix 0B):name 前缀 / results 扁平数组 / stdout 含 "R0 " 与 "K1 " /
6
+ // 退出码 0(通过) 1(插件缺陷) 2(用法错误) 语义不变。新增码 3/4/5/6 只在原本会误判的场景出现。
4
7
  import path from 'node:path'
5
- import { readFileSync, existsSync, mkdtempSync, writeFileSync } from 'node:fs'
8
+ import { readFileSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
6
9
  import { tmpdir } from 'node:os'
7
- import { Doctor, verdict, render } from './lib/framework.mjs'
10
+ import { Doctor, verdict, render, summarizeGroups, degradedGroups } from './lib/framework.mjs'
8
11
  import { addChecks as addPackageChecks } from './lib/checks-package.mjs'
9
12
  import { addChecks as addCordisChecks } from './lib/checks-cordis.mjs'
10
13
  import { addSmokeChecks } from './lib/checks-smoke.mjs'
11
14
  import { addChecks as addCollectionChecks } from './lib/checks-collections.mjs'
15
+ import { quarantineSandbox, redact } from './lib/util.mjs'
12
16
 
13
- const USAGE = `dsh-plugin-doctor —— dsh 插件完整性 + 运行流畅一体检测器
17
+ const VERSION = JSON.parse(readFileSync(path.join(import.meta.dirname, 'package.json'), 'utf8')).version
18
+ const SCHEMA_VERSION = '2'
19
+ const CHECKSET = 'R0-R8+K1-K9+D0-D3,D9+CC1-CC5/1'
20
+ const QUARANTINE_PREFIX = 'doctor-quarantine-'
21
+
22
+ const USAGE = `dsh-plugin-doctor ${VERSION} —— dsh 插件完整性 + 运行流畅一体检测器
14
23
 
15
24
  用法: node doctor.mjs --repo <插件仓路径> [选项]
16
25
 
17
26
  选项:
27
+ --repo, -r <路径> 被检插件仓(含 package.json)
28
+ --workspace, -w <路径> 兄弟仓所在工作区根(用于 CC 组核对本地清单;缺省=工具自身父目录)
18
29
  --no-smoke 跳过动态沙箱冒烟(默认执行;需网络 + pnpm)
19
- --dsh <版本> 冒烟宿主版本(默认 0.1.2-rc.1 = npm 已发布 latest)
30
+ --dsh <版本> 冒烟宿主版本(默认 ${'0.1.2-rc.1'} = npm 已发布 latest)
20
31
  --only <分组> 只跑指定分组(逗号分隔)。推荐用 ASCII 别名(编码安全):
21
32
  R = 静态·包结构
22
33
  K = 静态·cordis 契约扫描
23
34
  D = 动态·沙箱冒烟
24
35
  CC = 生态·集合站清单
25
36
  中文全名同样可用;大小写不敏感。
26
- --json <路径> 另存 JSON 报告
37
+ --allow-degraded 允许「被请求的组整组未真跑」时仍返回 0(默认返回 6)
38
+ --json <路径|-> 另存 JSON 报告;传 "-" 写 stdout(此时抑制人类可读报告)
39
+ --purge <隔离目录> 删除本工具自己产生的隔离目录(仅接受 ${QUARANTINE_PREFIX}* 前缀)
27
40
  -h, --help 显示帮助
41
+ -v, --version 显示版本
42
+
43
+ 退出码:
44
+ 0 = 无 fail/error(可含 warn/skip)
45
+ 1 = 存在 fail/error(插件缺陷)
46
+ 2 = 用法错误、未知分组或未知选项
47
+ 3 = 基础设施错误(如缺 npm/pnpm 等环境不可用)
48
+ 4 = 不支持的宿主版本(宿主安装失败,不判插件)
49
+ 5 = 结果不稳定(步骤超时)
50
+ 6 = 降级:被请求的组整组未真跑(如无源文件可扫描)—— 不得当作通过
28
51
 
29
- 退出码: 0 = fail/error(可含 warn/skip);1 = 存在 fail/error;2 = 用法/分组错误或零检查
30
- 安全: 冒烟全程使用 %TEMP% mkdtemp 临时 DSH_HOME,绝不触碰真实 ~/.dsh
52
+ 安全: 冒烟全程使用 %TEMP% 自建沙箱(前缀 doctor-,与宿主 %TEMP%\\dsh-* 保护模板不重叠),绝不触碰真实 ~/.dsh。
53
+ 运行结束只做「隔离不删除」(rename ${QUARANTINE_PREFIX}*),需人工确认后用 --purge 清理。
31
54
 
32
- 防静默通过: --only 里的分组名只要有一个不匹配(例如文件编码损坏导致中文变成乱码),
33
- 本工具立即以退出码 2 失败,绝不"零检查 + exit 0"。`
55
+ 防静默通过: --only 里的分组名只要有一个不匹配,本工具立即以退出码 2 失败。
56
+ 被请求的分组若整组未真跑(全 skip),以退出码 6 失败——除非显式 --allow-degraded。`
34
57
 
35
- // 分组 ASCII 别名(工作流里用它们可免受文件编码影响;中文全名仍可用)
36
58
  const GROUP_ALIASES = {
37
59
  R: '静态·包结构',
38
60
  K: '静态·cordis 契约扫描',
@@ -40,40 +62,76 @@ const GROUP_ALIASES = {
40
62
  CC: '生态·集合站清单',
41
63
  }
42
64
 
65
+ const FLAGS_WITH_VALUE = new Set(['--repo', '-r', '--workspace', '-w', '--dsh', '--json', '--only', '--purge'])
66
+
43
67
  function parseArgs(argv) {
44
- const opts = { repo: null, smoke: true, dshVersion: '0.1.2-rc.1', json: null, groups: null, help: false }
68
+ const opts = {
69
+ repo: null, workspace: null, smoke: true, dshVersion: '0.1.2-rc.1', json: null,
70
+ groups: null, help: false, version: false, allowDegraded: false, purge: null,
71
+ }
45
72
  for (let i = 0; i < argv.length; i++) {
46
73
  const a = argv[i]
47
74
  if (a === '--repo' || a === '-r') opts.repo = argv[++i]
75
+ else if (a === '--workspace' || a === '-w') opts.workspace = argv[++i]
48
76
  else if (a === '--no-smoke') opts.smoke = false
49
77
  else if (a === '--dsh') opts.dshVersion = argv[++i]
50
78
  else if (a === '--json') opts.json = argv[++i]
51
- else if (a === '--only') opts.groups = argv[++i].split(',').map((s) => s.trim())
79
+ else if (a === '--only') opts.groups = String(argv[++i] ?? '').split(',').map((s) => s.trim())
80
+ else if (a === '--purge') opts.purge = argv[++i]
81
+ else if (a === '--allow-degraded') opts.allowDegraded = true
52
82
  else if (a === '--help' || a === '-h') opts.help = true
53
- else if (!a.startsWith('-')) opts.repo = a
83
+ else if (a === '--version' || a === '-v') opts.version = true
84
+ else if (a.startsWith('-')) {
85
+ // 未知选项必须报错:旧实现会把未识别长选项的值当作位置参数、静默改写 --repo(静默错靶)
86
+ return { ...opts, error: `未知选项: ${a}` }
87
+ } else if (opts.repo === null) {
88
+ opts.repo = a
89
+ } else {
90
+ return { ...opts, error: `多余的位置参数: ${a}(--repo 已由前一个位置参数占用)` }
91
+ }
92
+ if (FLAGS_WITH_VALUE.has(a) && argv[i] === undefined) return { ...opts, error: `选项 ${a} 缺少取值` }
54
93
  }
55
94
  return opts
56
95
  }
57
96
 
58
- async function main() {
97
+ function main() {
59
98
  const opts = parseArgs(process.argv.slice(2))
60
- if (opts.help) { console.log(USAGE); return }
61
- if (!opts.repo) { console.error('缺少 --repo <路径>\n\n' + USAGE); process.exitCode = 2; return }
99
+ if (opts.help) { console.log(USAGE); return 0 }
100
+ if (opts.version) { console.log(VERSION); return 0 }
101
+ if (opts.error) { console.error(`${opts.error}\n\n${USAGE}`); return 2 }
102
+
103
+ // --purge:三段式的第三段(人工确认后清理)。只接受本工具自己的隔离目录。
104
+ if (opts.purge) {
105
+ const abs = path.resolve(opts.purge)
106
+ if (!path.basename(abs).startsWith(QUARANTINE_PREFIX)) {
107
+ console.error(`拒绝清理:只接受 ${QUARANTINE_PREFIX}* 前缀的隔离目录(收到 ${path.basename(abs)})`)
108
+ return 2
109
+ }
110
+ try { rmSync(abs, { recursive: true, force: true }) } catch (e) { console.error(`清理失败: ${e.message}`); return 3 }
111
+ console.log(`已清理隔离目录: ${abs}`)
112
+ return 0
113
+ }
114
+
115
+ if (!opts.repo) { console.error('缺少 --repo <路径>\n\n' + USAGE); return 2 }
62
116
  const repoPath = path.resolve(opts.repo)
63
117
  const pkgPath = path.join(repoPath, 'package.json')
64
- if (!existsSync(pkgPath)) { console.error(`未找到 ${pkgPath}`); process.exitCode = 2; return }
118
+ if (!existsSync(pkgPath)) { console.error(`未找到 ${pkgPath}`); return 2 }
65
119
  let pkg
66
- try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch (e) {
67
- console.error(`package.json 解析失败: ${e.message}`); process.exitCode = 2; return
68
- }
69
- const logDir = mkdtempSync(path.join(tmpdir(), 'dsh-doctor-logs-'))
120
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch (e) { console.error(`package.json 解析失败: ${e.message}`); return 2 }
121
+
122
+ const runRoot = mkdtempSync(path.join(tmpdir(), 'doctor-run-'))
123
+ const logDir = path.join(runRoot, 'logs')
124
+ mkdirSync(logDir, { recursive: true })
125
+ const workspaceRoot = opts.workspace ? path.resolve(opts.workspace) : path.resolve(import.meta.dirname, '..')
70
126
  const ctx = {
71
127
  repoPath,
72
128
  pkg,
73
129
  pkgName: pkg.name,
74
130
  logDir,
75
- workspaceRoot: path.resolve(import.meta.dirname, '..'),
131
+ workspaceRoot,
132
+ sandboxRoots: [runRoot],
76
133
  }
134
+
77
135
  const doctor = new Doctor()
78
136
  addPackageChecks(doctor, ctx)
79
137
  addCordisChecks(doctor, ctx)
@@ -82,34 +140,113 @@ async function main() {
82
140
 
83
141
  const knownGroups = [...new Set(doctor.checks.map((c) => c.group))]
84
142
  if (opts.groups) {
85
- // 别名 → 分组全名;大小写不敏感。未命中的 token 原样保留,下面统一报错。
86
143
  const resolved = opts.groups.map((t) => GROUP_ALIASES[t.toUpperCase()] ?? t)
87
144
  const unknown = resolved.filter((g) => !knownGroups.includes(g))
88
145
  if (unknown.length > 0) {
146
+ const smokeHint = !opts.smoke && unknown.some((g) => GROUP_ALIASES.D === g)
147
+ ? '\n注:D 组需要动态冒烟,不能与 --no-smoke 同时请求(要么去掉 --no-smoke,要么从 --only 里去掉 D)'
148
+ : ''
89
149
  console.error(
90
150
  `未知分组: ${unknown.join(', ')}\n` +
91
- `可用分组: ${knownGroups.map((g) => `${g}(${Object.keys(GROUP_ALIASES).find((a) => GROUP_ALIASES[a] === g) ?? '-'})`).join(', ')}`,
151
+ `可用分组: ${knownGroups.map((g) => `${g}(${Object.keys(GROUP_ALIASES).find((a) => GROUP_ALIASES[a] === g) ?? '-'})`).join(', ')}${smokeHint}`,
92
152
  )
93
- process.exitCode = 2
94
- return
153
+ return 2
95
154
  }
96
155
  opts.groups = resolved
97
156
  }
98
157
 
99
- const results = await doctor.run(ctx, { groups: opts.groups })
158
+ const started = Date.now()
159
+ const raw = doctor.run(ctx, { groups: opts.groups })
160
+ return Promise.resolve(raw).then((results) => finish(results, { opts, ctx, repoPath, pkg, runRoot, logDir, started }))
161
+ }
162
+
163
+ function finish(results, { opts, ctx, repoPath, pkg, runRoot, logDir, started }) {
100
164
  if (results.length === 0) {
101
165
  console.error('没有任何检查被执行(--only 未匹配到任何分组)——拒绝静默通过,退出码 2')
102
- process.exitCode = 2
103
- return
166
+ return 2
104
167
  }
105
- console.log(`# dsh-plugin-doctor 报告\n目标: ${repoPath}\n包名: ${ctx.pkgName}`)
106
- console.log(render(results))
107
- console.log(`日志目录: ${logDir}`)
108
- if (opts.json) {
109
- writeFileSync(opts.json, JSON.stringify({ repo: repoPath, pkgName: ctx.pkgName, results }, null, 2), 'utf8')
110
- console.log(`JSON 报告: ${opts.json}`)
168
+
169
+ // 脱敏:把本次运行注入的绝对路径替换为占位符(R-fix 0C)
170
+ const redactPaths = [repoPath, logDir, runRoot, ...(ctx.sandboxRoots ?? [])]
171
+ for (const r of results) {
172
+ if (typeof r.message === 'string') r.message = redact(r.message, redactPaths)
173
+ if (typeof r.evidence === 'string') r.evidence = redact(r.evidence, redactPaths)
174
+ }
175
+
176
+ const groups = summarizeGroups(results)
177
+ const degraded = degradedGroups(groups)
178
+ const v = verdict(results, { degraded })
179
+ const durationMs = Date.now() - started
180
+
181
+ const envelope = {
182
+ schemaVersion: SCHEMA_VERSION,
183
+ doctorVersion: VERSION,
184
+ checksetVersion: CHECKSET,
185
+ target: {
186
+ repo: repoPath,
187
+ pkgName: pkg.name,
188
+ pkgVersion: pkg.version ?? null,
189
+ integrity: null, // 取自 registry(npm view <pkg>@<ver> dist.integrity);R/K 离线档不解析
190
+ integritySource: 'unresolved',
191
+ },
192
+ env: {
193
+ node: process.version,
194
+ platform: process.platform,
195
+ arch: process.arch,
196
+ hostDshVersion: opts.smoke ? opts.dshVersion : null,
197
+ smoke: opts.smoke,
198
+ },
199
+ startedAt: new Date(started).toISOString(),
200
+ durationMs,
201
+ quarantine: null,
202
+ coverage: ctx.coverage ?? {},
203
+ groups,
204
+ degraded,
205
+ verdict: { worst: v.worst, ok: v.ok, criticalFail: v.criticalFail },
206
+ results,
207
+ }
208
+
209
+ const qPath = quarantineSandbox({ root: runRoot })
210
+ envelope.quarantine = qPath
211
+
212
+ const code = exitCodeFor(results, { degraded, allowDegraded: opts.allowDegraded })
213
+
214
+ if (opts.json === '-') {
215
+ process.stdout.write(JSON.stringify(envelope, null, 2) + '\n')
216
+ } else {
217
+ console.log(`# dsh-plugin-doctor 报告\n目标: ${repoPath}\n包名: ${pkg.name}`)
218
+ console.log(render(results))
219
+ if (degraded.length) {
220
+ console.log(`\n[降级] 以下分组整组未真跑(全 skip),本次结论不可当作通过: ${degraded.join(', ')}`)
221
+ if (!opts.allowDegraded) console.log(' 如需显式接受,请加 --allow-degraded(退出码将降为 0)')
222
+ }
223
+ console.log(`沙箱隔离目录: ${qPath ?? '(未创建)'}(人工确认后用 --purge 清理)`)
224
+ if (opts.json) {
225
+ writeFileSync(opts.json, JSON.stringify(envelope, null, 2) + '\n', 'utf8')
226
+ console.log(`JSON 报告: ${opts.json}`)
227
+ }
228
+ }
229
+ return code
230
+ }
231
+
232
+ // 退出码映射:fail/error 优先(按 category 细分 3/4/5),其次「整组未真跑」→ 6。
233
+ function exitCodeFor(results, { degraded, allowDegraded }) {
234
+ const bad = results.filter((r) => r.status === 'fail' || r.status === 'error')
235
+ if (bad.length) {
236
+ const cats = [...new Set(bad.map((r) => r.category))]
237
+ if (cats.length === 1) {
238
+ if (cats[0] === 'unsupported-host') return 4
239
+ if (cats[0] === 'infrastructure') return 3
240
+ if (cats[0] === 'unstable') return 5
241
+ }
242
+ return 1
111
243
  }
112
- process.exitCode = verdict(results).ok ? 0 : 1
244
+ if (degraded.length && !allowDegraded) return 6
245
+ return 0
113
246
  }
114
247
 
115
- main().catch((err) => { console.error(err); process.exitCode = 2 })
248
+ const out = main()
249
+ Promise.resolve(out).then((code) => { process.exitCode = typeof code === 'number' ? code : 0 }).catch((err) => {
250
+ console.error(err)
251
+ process.exitCode = 3
252
+ })
@@ -3,7 +3,7 @@
3
3
  // adp-list 收录条目 schema、dsh-catalog 市场目录、omdsh Workshop v2、dsh-plugin-kit 三门。
4
4
  import path from 'node:path'
5
5
  import { readFileSync, existsSync, readdirSync } from 'node:fs'
6
- import { runStep, pass, fail, warn, skip, tail, readJson, findFiles } from './util.mjs'
6
+ import { runStep, pass, fail, warn, skip, na, tail, readJson, findFiles } from './util.mjs'
7
7
 
8
8
  export const GROUP = '生态·集合站清单'
9
9
 
@@ -99,31 +99,21 @@ export function addChecks(doctor, ctx) {
99
99
  })
100
100
 
101
101
  doctor.add(GROUP, 'CC5 插件三门(license/五语 README/seam 三角色)', () => {
102
+ // R-fix 2:这三门(Apache-2.0 + 五语 README + seam 三角色 marker)是 PerryLink 家族私有标准,
103
+ // 不是生态标准。旧实现在没有家族 workspace 时改跑「本地降级检查」并把它施加到目标仓上 →
104
+ // 任何外部作者跑 --only CC 都会被判 fail(不是环境假红,是真判据),且该 fail 会被误读为工具故障。
105
+ // 现在:家族私有标准只在家族工作区内核对(kit CLI 存在),其余显式 not-applicable。
102
106
  const kitCli = path.join(ws, 'dsh-plugin-kit', 'lib', 'verify', 'cli.js')
103
- if (existsSync(kitCli)) {
104
- const r = runStep('cc5-kit', process.execPath, [kitCli, 'all', repoPath], { cwd: ws, logDir: ctx.logDir, timeout: 120_000, shell: false })
105
- if (r.ok) return pass('dsh-plugin-kit 三门 CLI 全过')
106
- const out = `${r.out}\n${r.err}`
107
- const note = /no source files found under src/.test(out)
108
- ? '\n注:纯 JS 仓无 src/ 目录,kit verify-seam 结构不适用(建议 kit 扩展或人工豁免)' : ''
109
- if (r.code === 1) return fail(`dsh-plugin-kit 三门失败:\n${tail(out, 12)}${note}`)
110
- return fail(`dsh-plugin-kit 三门执行异常(exit ${r.code}):\n${tail(r.err)}${note}`)
107
+ if (!existsSync(kitCli)) {
108
+ return na('家族私有标准(Apache-2.0 / 五语 README / seam 三角色)不适用于非家族仓;'
109
+ + '如需核对请在家族工作区内运行(--workspace 指向含 dsh-plugin-kit 的目录)')
111
110
  }
112
- // 本地降级等价检查(kit lib 未构建时)
113
- const problems = []
114
- const hasLicenseFile = ['LICENSE', 'LICENSE.md', 'LICENSE.txt'].some((f) => existsSync(path.join(repoPath, f)))
115
- if (!hasLicenseFile || !pkg.license) problems.push('缺 LICENSE 文件或 license 字段(kit 三门: error)')
116
- else if (pkg.license !== 'Apache-2.0') problems.push(`license=${pkg.license}(kit 三门: warning,期望 Apache-2.0)`)
117
- for (const lang of ['', '.zh', '.es', '.pt', '.hi']) {
118
- const p = path.join(repoPath, `README${lang}.md`)
119
- if (!existsSync(p)) problems.push(`缺 README${lang}.md`)
120
- }
121
- const srcFiles = findFiles(repoPath, 'src', /\.(ts|tsx|mjs|js)$/)
122
- const all = srcFiles.map((f) => { try { return readFileSync(f, 'utf8') } catch { return '' } }).join('\n')
123
- for (const marker of ['Service Definition', 'Service Provider', 'Consumer']) {
124
- if (!all.includes(marker)) problems.push(`src 缺 seam 三角色 marker: ${marker}`)
125
- }
126
- if (problems.length) return fail(`三门(本地降级检查)未过:\n${problems.join('\n')}`)
127
- return pass('三门本地降级检查通过(kit CLI 未构建,跳过官方实现)')
111
+ const r = runStep('cc5-kit', process.execPath, [kitCli, 'all', repoPath], { cwd: ws, logDir: ctx.logDir, timeout: 120_000, shell: false })
112
+ if (r.ok) return pass('dsh-plugin-kit 三门 CLI 全过')
113
+ const out = `${r.out}\n${r.err}`
114
+ const note = /no source files found under src/.test(out)
115
+ ? '\n注:该仓无 src/ 目录,kit verify-seam 结构不适用(kit 已支持根层 JS 回退,此处以 kit 输出为准)' : ''
116
+ if (r.code === 1) return fail(`dsh-plugin-kit 三门失败:\n${tail(out, 12)}${note}`)
117
+ return fail(`dsh-plugin-kit 三门执行异常(exit ${r.code}):\n${tail(r.err)}${note}`)
128
118
  })
129
119
  }
@@ -37,18 +37,38 @@ function readAll(files) {
37
37
 
38
38
  const short = (f) => f.split(/[\\/]/).pop()
39
39
 
40
- // 源文件发现策略:src/** + 根目录一层 JS/TS;纯 JS 仓(无 build 脚本且 main 指向 lib/)的 lib 即源码
40
+ // 源文件发现策略(R-fix 0A 修正):
41
+ // ① src/** + 根目录一层 JS/TS
42
+ // ② 无 src 时,若 main 指向 lib/ → 把 lib/** 当源码(**去掉旧的 `!pkg.scripts?.build` 门槛**:
43
+ // 已发布 tarball / 已装包目录里没有 src/,但 package.json 仍保留 build 脚本,
44
+ // 旧条件因此永不成立 → K1–K9 九项全 skip → 汇总仍 exit 0(假绿))
45
+ // ③ main 未做 stripDot 时 `./index.mjs` 不匹配 'lib/',7 个仓的 lib/ 从不被扫(同样修正)
46
+ // 覆盖率写入 ctx.coverage.K,供报告与「整组未真跑」判定使用。
41
47
  function collectSourceFiles(ctx) {
42
48
  const { repoPath, pkg } = ctx
43
- const files = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
49
+ const main = String(pkg.main ?? '').replace(/^\.\//, '')
50
+ const srcFiles = findFiles(repoPath, 'src', /\.(ts|mts|tsx|mjs|js)$/)
44
51
  for (const entry of readdirSync(repoPath, { withFileTypes: true })) {
45
52
  if (!entry.isFile()) continue
46
- if (/\.(mjs|js|cjs|ts|mts|cts)$/.test(entry.name)) files.push(path.join(repoPath, entry.name))
53
+ if (/\.(mjs|js|cjs|ts|mts|cts)$/.test(entry.name)) srcFiles.push(path.join(repoPath, entry.name))
47
54
  }
48
- if (!pkg.scripts?.build && String(pkg.main ?? '').startsWith('lib/')) {
49
- files.push(...findFiles(repoPath, 'lib', /\.(mjs|js|cjs)$/))
55
+ let files = [...new Set(srcFiles)]
56
+ let mode = 'src'
57
+ let fallbackDir = null
58
+ if (!files.length) {
59
+ // 无 src 时按「构建产物目录」兜底:main 所在目录 + lib/ + dist/。
60
+ // 不再要求 main 必须以 'lib/' 开头 —— 实测第三方插件里 main 落在 dist/ 或包根是常见形态,
61
+ // 只认 lib/ 会让这些包 K 组九项全 skip(本仓 THIRD-PARTY-RK-SCAN.md 里 3/20 即此情形)。
62
+ const mainDir = path.posix.dirname(main)
63
+ const dirs = [...new Set([mainDir !== '.' && mainDir !== '' ? mainDir : null, 'lib', 'dist'].filter(Boolean))]
64
+ for (const d of dirs) {
65
+ const hit = findFiles(repoPath, d, /\.(mjs|js|cjs)$/)
66
+ if (hit.length) { files = [...new Set(hit)]; mode = 'lib-fallback'; fallbackDir = d; break }
67
+ }
50
68
  }
51
- return [...new Set(files)]
69
+ if (!files.length) mode = 'none'
70
+ ctx.coverage = { ...(ctx.coverage ?? {}), K: { filesInspected: files.length, mode, fallbackDir } }
71
+ return files
52
72
  }
53
73
 
54
74
  export function addChecks(doctor, ctx) {
@@ -123,7 +123,9 @@ export function addChecks(doctor, ctx) {
123
123
  const importsCordis = srcFiles.some((f) => { try { return /from\s+['"]@deepseek-ai\/cordis['"]|require\(['"]@deepseek-ai\/cordis['"]\)/.test(readFileSync(f, 'utf8')) } catch { return false } })
124
124
  if (!peer && !dep) {
125
125
  if (importsCordis) problems.push('源码 import @deepseek-ai/cordis 但未声明任何依赖')
126
- else return pass('未声明 @deepseek-ai/cordis 且源码无 import(纯 JS 仓可无 cordis 运行时依赖)')
126
+ // R-fix 0A / P7:无源文件时旧实现直接 pass(真·静默通过,连 skip 痕迹都不留),而 R5 属被门禁的 16 项之一。
127
+ else if (srcFiles.length) return pass('未声明 @deepseek-ai/cordis 且源码无 import(纯 JS 仓可无 cordis 运行时依赖)')
128
+ else return skip('无源文件可判(无 src/ 且未声明 @deepseek-ai/cordis)——不计 pass')
127
129
  } else if (!peer) {
128
130
  problems.push('@deepseek-ai/cordis 只在 dependencies(官方口径:peerDependencies + devDependencies 同时声明)')
129
131
  }
@@ -1,8 +1,13 @@
1
- // 动态·沙箱冒烟(D0–D3 + 清场)
1
+ // 动态·沙箱冒烟(D0–D3 + D9)
2
2
  // 依据:dsh compat.yml 正典配方(MISSING_CREDENTIAL 判据)+ 2026-09-07 harness 调研 +
3
- // 工作区红线 3(一切测试全沙箱:%TEMP% mkdtemp DSH_HOME)
3
+ // 工作区红线 3(一切测试全沙箱)
4
+ //
5
+ // 本轮(R-fix 6/7)两处硬化:
6
+ // 1. `dsh plugin add` 显式传 --ignore-scripts —— 被测包的 install/prepare 脚本不得在宿主执行;
7
+ // pnpm 的 ignored-builds 阻断归类为 environment(不计 pass、不计插件缺陷)。
8
+ // 2. D9 由「直接 rmSync」改为「隔离不删除」(红线 4);D0 的 tarball 落进沙箱而非被检仓。
4
9
  import path from 'node:path'
5
- import { makeSandbox, cleanSandbox, runStep, pass, fail, skip, tail, readJson, writeJson, exists } from './util.mjs'
10
+ import { makeSandbox, quarantineSandbox, runStep, pass, fail, skip, envskip, tail, readJson, writeJson, exists } from './util.mjs'
6
11
 
7
12
  export const GROUP = '动态·沙箱冒烟'
8
13
 
@@ -11,9 +16,11 @@ export function addSmokeChecks(doctor, ctx, opts = {}) {
11
16
  const dshVersion = opts.dshVersion ?? '0.1.2-rc.1'
12
17
  const state = {}
13
18
 
14
- doctor.add(GROUP, 'D0 打包 tarball(npm pack', () => {
19
+ doctor.add(GROUP, 'D0 打包 tarball(npm pack,禁止生命周期脚本)', () => {
15
20
  state.sb = makeSandbox('smoke')
16
- const r = runStep('d0-pack', 'npm', ['pack', '--json', '--ignore-scripts'], { cwd: repoPath, logDir, timeout: 300_000 })
21
+ ctx.sandboxRoots.push(state.sb.root)
22
+ // --pack-destination 指向沙箱:旧实现把 .tgz 直接落在被检仓里且从不清理(实测 8 仓遗留 17 个 stray .tgz)
23
+ const r = runStep('d0-pack', 'npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', state.sb.root], { cwd: repoPath, logDir, timeout: 300_000 })
17
24
  if (!r.ok) return fail(`npm pack 失败(exit ${r.code}):\n${tail(r.err)}`)
18
25
  let pack
19
26
  try {
@@ -22,35 +29,47 @@ export function addSmokeChecks(doctor, ctx, opts = {}) {
22
29
  } catch {
23
30
  return fail('npm pack --json 解析失败')
24
31
  }
25
- state.tgz = path.join(repoPath, pack.filename)
26
- if (!exists(state.tgz)) return fail(`tarball 未生成: ${state.tgz}`)
27
- return pass(`已打包 ${pack.filename}(${pack.files?.length ?? '?'} 文件)`)
32
+ state.tgz = path.join(state.sb.root, pack.filename)
33
+ if (!exists(state.tgz)) return fail(`tarball 未生成: ${pack.filename}`)
34
+ return pass(`已打包 ${pack.filename}(${pack.files?.length ?? '?'} 文件,落沙箱)`)
28
35
  })
29
36
 
30
37
  doctor.add(GROUP, 'D1 安装冒烟(plugin add + bundles 断言)', () => {
31
38
  if (!state.tgz) return skip('D0 未通过')
32
39
  writeJson(path.join(state.sb.root, 'package.json'), { name: 'dsh-doctor-runtime', version: '0.0.0', private: true })
33
- const i = runStep('d1-install', 'npm', ['install', '--no-audit', '--no-fund', '--loglevel=error', `@deepseek-ai/dsh@${dshVersion}`], {
40
+ const i = runStep('d1-host', 'npm', ['install', '--no-audit', '--no-fund', '--loglevel=error', `@deepseek-ai/dsh@${dshVersion}`], {
34
41
  cwd: state.sb.root, logDir, timeout: 900_000,
35
42
  })
36
- if (!i.ok) return fail(`安装 @deepseek-ai/dsh@${dshVersion} 失败(exit ${i.code}):\n${tail(i.err)}`)
43
+ if (i.spawnError || i.code === 127) {
44
+ return { status: 'fail', message: `环境不可用:找不到 npm/node(${i.spawnError ?? 'exit 127'}):\n${tail(i.err)}`, category: 'infrastructure' }
45
+ }
46
+ if (!i.ok) {
47
+ // 宿主安装失败 ≠ 插件缺陷:归类 unsupported-host(退出码 4)
48
+ return { status: 'fail', message: `安装 @deepseek-ai/dsh@${dshVersion} 失败(exit ${i.code})—— 宿主版本不可用,不判插件:\n${tail(i.err)}`, category: 'unsupported-host' }
49
+ }
37
50
  const hostPkgPath = path.join(state.sb.root, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
38
- if (!exists(hostPkgPath)) return fail('@deepseek-ai/dsh 未安装成功')
51
+ if (!exists(hostPkgPath)) return { status: 'fail', message: '@deepseek-ai/dsh 未安装成功', category: 'unsupported-host' }
39
52
  const host = readJson(hostPkgPath)
40
53
  const rel = host.bin?.dsh
41
54
  const bin = rel ? path.resolve(state.sb.root, 'node_modules', '@deepseek-ai', 'dsh', rel) : null
42
- if (!bin || !exists(bin)) return fail(`未找到 dsh bin(package.json bin=${JSON.stringify(host.bin)})`)
55
+ if (!bin || !exists(bin)) return { status: 'fail', message: `未找到 dsh bin(package.json bin=${JSON.stringify(host.bin)})`, category: 'infrastructure' }
43
56
  state.bin = bin
44
57
  const dsh = (label, args, o = {}) => runStep(label, process.execPath, [bin, ...args], {
45
58
  env: { DSH_HOME: state.sb.home, DSH_AGENTS_HOME: path.join(state.sb.home, '.agents'), ...(o.env ?? {}) },
46
59
  cwd: state.sb.root, logDir, timeout: o.timeout ?? 120_000, shell: false,
47
60
  })
48
61
  state.dsh = dsh
49
- const a = dsh('d1-add', ['plugin', '--profile', 'headless', 'add', state.tgz], { timeout: 900_000 })
62
+ // --ignore-scripts 透传给 pnpm(dsh plugin pnpm 的透明转发器)
63
+ const a = dsh('d1-add', ['plugin', '--profile', 'headless', 'add', state.tgz, '--ignore-scripts'], { timeout: 900_000 })
50
64
  const envBlock = /ERR_PNPM_IGNORED_BUILDS|approve-builds|ignored builds/i.test(a.err)
65
+ const pnpmMissing = /pnpm not found on PATH|ENOENT/i.test(a.err) || a.code === 127
66
+ if (pnpmMissing) {
67
+ return { status: 'fail', message: `环境不可用:PATH 上找不到 pnpm(dsh plugin 是 pnpm 转发器):\n${tail(a.err)}`, category: 'infrastructure' }
68
+ }
51
69
  if (!a.ok) {
52
70
  if (envBlock) {
53
- return warn(`pnpm approve-builds/ignored-builds 环境门阻断(属环境配方问题,非插件缺陷,参照 compat.yml allowBuilds 配方):\n${tail(a.err)}`)
71
+ // 环境配方问题(pnpm 阻断 build scripts)→ environment,不计 pass、不计插件缺陷
72
+ return envskip(`pnpm approve-builds/ignored-builds 环境门阻断(属环境配方问题,非插件缺陷,参照 compat.yml allowBuilds 配方):\n${tail(a.err)}`)
54
73
  }
55
74
  return fail(`plugin add 失败(exit ${a.code}):\n${tail(a.err)}`)
56
75
  }
@@ -66,6 +85,7 @@ export function addSmokeChecks(doctor, ctx, opts = {}) {
66
85
  doctor.add(GROUP, 'D2 层验证(--dump-config,不 boot)', () => {
67
86
  if (!state.dsh) return skip('D1 未通过')
68
87
  const r = state.dsh('d2-dump', ['--profile', 'headless', '--dump-config'], { timeout: 120_000 })
88
+ if (r.signal) return { status: 'fail', message: `--dump-config 被信号终止(${r.signal})—— 结果不稳定`, category: 'unstable' }
69
89
  if (!r.ok) return fail(`--dump-config 失败(exit ${r.code}):\n${tail(r.err)}`)
70
90
  if (!r.out.includes(`# == ${pkgName}`)) return fail(`dump-config 未见层标记 "# == ${pkgName}"`)
71
91
  return pass(`层标记 "# == ${pkgName}" 出现(patch 已进入组合层)`)
@@ -75,7 +95,8 @@ export function addSmokeChecks(doctor, ctx, opts = {}) {
75
95
  if (!state.dsh) return skip('D1 未通过')
76
96
  const r = state.dsh('d3-run', ['--profile', 'headless', 'Reply with exactly: ok'], { timeout: 90_000 })
77
97
  const combined = `${r.out}\n${r.err}`
78
- if (r.signal || r.spawnError) return fail(`进程异常终止(signal=${r.signal}${r.spawnError ? ' ' + r.spawnError : ''})`)
98
+ if (r.signal) return { status: 'fail', message: `进程异常终止(signal=${r.signal})—— 结果不稳定`, category: 'unstable' }
99
+ if (r.spawnError) return { status: 'fail', message: `子进程未启动(${r.spawnError})—— 环境不可用`, category: 'infrastructure' }
79
100
  const bad = combined.match(/.*(NO_ADAPTER|ERR_MODULE_NOT_FOUND|SyntaxError|TypeError|ReferenceError|Cannot find module).*/g)
80
101
  if (bad) return fail(`组合未 boot 到请求阶段,出现致命错误:\n${bad.slice(0, 5).join('\n')}`)
81
102
  if (r.code === 1 && /dsh:\s*MISSING_CREDENTIAL/.test(combined)) {
@@ -85,9 +106,12 @@ export function addSmokeChecks(doctor, ctx, opts = {}) {
85
106
  return fail(`期望 exit 1 + MISSING_CREDENTIAL,实际 exit ${r.code}:\n${tail(combined)}`)
86
107
  })
87
108
 
88
- doctor.add(GROUP, 'D9 沙箱清理', () => {
89
- const wasRun = !!state.sb
90
- cleanSandbox(state.sb)
91
- return pass(wasRun ? '临时 DSH_HOME 沙箱已删除(%TEMP% mkdtemp)' : '未创建沙箱')
109
+ doctor.add(GROUP, 'D9 沙箱隔离(三段式:只隔离不删除)', () => {
110
+ const before = state.sb?.root ?? null
111
+ const moved = quarantineSandbox(state.sb)
112
+ state.sb = null
113
+ if (!before) return pass('未创建沙箱(无需隔离)')
114
+ if (!moved) return fail(`沙箱隔离失败:${before}(请人工检查后清理)`)
115
+ return pass(`沙箱已隔离到 ${moved}(未删除;人工确认后用 --purge 清理)`)
92
116
  })
93
117
  }
package/lib/framework.mjs CHANGED
@@ -1,4 +1,17 @@
1
- // dsh-plugin-doctor 检测框架:零依赖,检查注册 / 运行 / 判定 / 渲染
1
+ // 检测框架:零依赖,检查注册 / 运行 / 判定 / 渲染
2
+ //
3
+ // 不可变契约(R-fix 0B 冻结;家族 37 仓的 CI 依赖它们,任何改动都会造成硬红):
4
+ // 1. 结果项 res.name 保留 `^R[0-8] / ^K[1-9] / ^D\d / ^CC\d ` 前缀 —— ID 只新增字段,绝不从 name 里拆走
5
+ // 2. 结果项仍是扁平数组(JSON 的 .results),按注册顺序稳定输出
6
+ // 3. 渲染行的形态不变(`[STATUS] <name>`),stdout 必须仍含 "R0 " 与 "K1 "
7
+ // 4. skip 不算 fail(verdict.ok 只看 fail/error)
8
+ export const GROUP_IDS = {
9
+ '静态·包结构': 'R',
10
+ '静态·cordis 契约扫描': 'K',
11
+ '动态·沙箱冒烟': 'D',
12
+ '生态·集合站清单': 'CC',
13
+ }
14
+
2
15
  export class Doctor {
3
16
  constructor() {
4
17
  this.checks = []
@@ -18,19 +31,44 @@ export class Doctor {
18
31
  if (typeof res === 'string') res = { status: 'pass', message: res }
19
32
  if (!res || typeof res !== 'object') res = { status: 'pass', message: String(res) }
20
33
  } catch (err) {
21
- res = { status: 'error', message: err && err.stack ? err.stack : String(err) }
34
+ res = { status: 'error', message: err && err.stack ? err.stack : String(err), category: 'doctor-internal' }
22
35
  }
23
36
  res.group = c.group
24
37
  res.name = c.name
25
38
  res.critical = !!c.opts.critical
39
+ // 稳定 ID 从 name 前缀派生(只读,不改写 name)
40
+ const m = /^([A-Z]{1,3})(\d+)\s/.exec(c.name)
41
+ res.id = m ? `${m[1]}${m[2]}` : null
42
+ res.groupId = GROUP_IDS[c.group] ?? null
43
+ if (!res.category) res.category = res.status === 'skip' ? 'not-applicable' : 'plugin-defect'
26
44
  results.push(res)
27
45
  }
28
46
  return results
29
47
  }
30
48
  }
31
49
 
50
+ /** 按组统计:total / ran(非 skip)/ skipped / 各状态计数 / 累计 filesInspected */
51
+ export function summarizeGroups(results) {
52
+ const out = {}
53
+ for (const r of results) {
54
+ const g = r.groupId ?? '?'
55
+ const s = (out[g] ??= { total: 0, ran: 0, skipped: 0, pass: 0, warn: 0, fail: 0, error: 0, skip: 0, filesInspected: 0 })
56
+ s.total += 1
57
+ s[r.status] = (s[r.status] ?? 0) + 1
58
+ if (r.status === 'skip') s.skipped += 1
59
+ else s.ran += 1
60
+ if (typeof r.filesInspected === 'number') s.filesInspected += r.filesInspected
61
+ }
62
+ return out
63
+ }
64
+
65
+ /** 被请求、但一条都没真跑的组(全部 skip)→ 该次运行「降级」,不得裸报成功 */
66
+ export function degradedGroups(groups) {
67
+ return Object.entries(groups).filter(([, v]) => v.total > 0 && v.ran === 0).map(([k]) => k)
68
+ }
69
+
32
70
  // 最坏状态排序:error/fail > warn > skip > pass
33
- export function verdict(results) {
71
+ export function verdict(results, { degraded = [] } = {}) {
34
72
  let worst = 'pass'
35
73
  for (const r of results) {
36
74
  if (r.status === 'error' && worst !== 'error') worst = 'error'
@@ -40,7 +78,7 @@ export function verdict(results) {
40
78
  }
41
79
  const criticalFail = results.some((r) => r.critical && (r.status === 'fail' || r.status === 'error'))
42
80
  // 退出码契约:0 = 无 fail/error(可含 warn/skip);1 = 存在 fail/error
43
- return { worst, criticalFail, ok: worst !== 'fail' && worst !== 'error' }
81
+ return { worst, criticalFail, ok: worst !== 'fail' && worst !== 'error', degraded }
44
82
  }
45
83
 
46
84
  const ICONS = { pass: '[PASS]', warn: '[WARN]', fail: '[FAIL]', error: '[ERROR]', skip: '[SKIP]' }
package/lib/util.mjs CHANGED
@@ -1,14 +1,19 @@
1
- // 工具:临时 DSH_HOME 沙箱 + 子进程执行(输出落盘,规避管道捕获限制)
1
+ // 工具:临时沙箱 + 子进程执行(输出落盘,规避管道捕获限制)
2
2
  import { spawnSync } from 'node:child_process'
3
3
  import {
4
- mkdtempSync, mkdirSync, rmSync, openSync, closeSync, readFileSync, writeFileSync, existsSync, readdirSync,
4
+ mkdtempSync, mkdirSync, renameSync, openSync, closeSync, readFileSync, writeFileSync, existsSync, readdirSync,
5
5
  } from 'node:fs'
6
6
  import { tmpdir } from 'node:os'
7
7
  import path from 'node:path'
8
8
 
9
- // 沙箱目录一律建在 %TEMP%,绝不触碰真实 ~/.dsh(红线 3
9
+ // 沙箱目录一律建在 %TEMP%,绝不触碰真实 ~/.dsh(红线 3)。
10
+ // 前缀用 `doctor-` 而非 `dsh-`:宿主运行目录的保护模板是 %TEMP%\dsh-*(红线 1),
11
+ // 用 `dsh-doctor-*` 会与该模板相撞;改名后本工具的自建临时目录不再落在保护模板内。
12
+ const SANDBOX_PREFIX = 'doctor-sbx-'
13
+ const QUARANTINE_PREFIX = 'doctor-quarantine-'
14
+
10
15
  export function makeSandbox(label) {
11
- const root = mkdtempSync(path.join(tmpdir(), `dsh-doctor-${label}-`))
16
+ const root = mkdtempSync(path.join(tmpdir(), `${SANDBOX_PREFIX}${label}-`))
12
17
  const home = path.join(root, 'home')
13
18
  const logs = path.join(root, 'logs')
14
19
  mkdirSync(home, { recursive: true })
@@ -16,10 +21,36 @@ export function makeSandbox(label) {
16
21
  return { root, home, logs }
17
22
  }
18
23
 
19
- export function cleanSandbox(sb) {
20
- if (sb && sb.root) {
21
- try { rmSync(sb.root, { recursive: true, force: true }) } catch {}
24
+ // 清理改为「隔离不删除」(红线 4 三段式的前两段:dry-run 打印 → rename 到隔离目录)。
25
+ // 返回隔离后的绝对路径;调用方必须打印它,人工确认后再自行清理。
26
+ // 本工具不再有任何 rmSync/rm -rf 路径 —— 旧的 cleanSandbox 已移除。
27
+ export function quarantineSandbox(sb) {
28
+ if (!sb || !sb.root || !existsSync(sb.root)) return null
29
+ const dest = path.join(tmpdir(), `${QUARANTINE_PREFIX}${Date.now()}-${path.basename(sb.root)}`)
30
+ try {
31
+ renameSync(sb.root, dest)
32
+ return dest
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ // 把运行期绝对路径替换为占位符,使 message 可对外复用(R-fix 0C / L1)。
39
+ // 只处理"本次运行自己注入的路径",不做通用脱敏。
40
+ export function redact(text, paths = []) {
41
+ let s = String(text ?? '')
42
+ const variants = []
43
+ for (const p of paths) {
44
+ if (!p || typeof p !== 'string') continue
45
+ variants.push(p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\'))
46
+ }
47
+ const tmp = tmpdir()
48
+ variants.push(tmp, tmp.replace(/\\/g, '/'), tmp.replace(/\//g, '\\'))
49
+ for (const v of [...new Set(variants)].sort((a, b) => b.length - a.length)) {
50
+ if (v.length < 4) continue
51
+ s = s.split(v).join('<path>')
22
52
  }
53
+ return s
23
54
  }
24
55
 
25
56
  // 执行命令:stdout/stderr 分别写入日志文件(不建管道),返回退出码与全文
@@ -63,6 +94,10 @@ export const pass = (message) => ({ status: 'pass', message })
63
94
  export const fail = (message) => ({ status: 'fail', message })
64
95
  export const warn = (message) => ({ status: 'warn', message })
65
96
  export const skip = (reason) => ({ status: 'skip', message: reason })
97
+ /** 显式不可判:与 skip 同判定,但带 category 以便上游区分「环境不适用」与「确实没跑」 */
98
+ export const na = (reason, category = 'not-applicable') => ({ status: 'skip', message: reason, category })
99
+ /** 环境类降级:不计 pass、不计插件缺陷,走 category=environment */
100
+ export const envskip = (reason) => ({ status: 'skip', message: reason, category: 'environment' })
66
101
 
67
102
  export function readJson(p) {
68
103
  return JSON.parse(readFileSync(p, 'utf8'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perrylink/dsh-plugin-doctor",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Zero-dependency static + sandbox smoke detector for DeepSeek Harness (dsh) plugins: package-structure gates (R), cordis contract scans (K), keyless-headless sandbox smoke (D), and ecosystem-listing checks (CC).",
5
5
  "type": "module",
6
6
  "main": "doctor.mjs",
@@ -41,7 +41,7 @@
41
41
  "url": "https://github.com/sponsors/PerryLink"
42
42
  },
43
43
  "scripts": {
44
- "test": "node --check doctor.mjs && node --check lib/framework.mjs && node --check lib/util.mjs && node --check lib/checks-package.mjs && node --check lib/checks-cordis.mjs && node --check lib/checks-smoke.mjs && node --check lib/checks-collections.mjs && node --check scripts/badge.mjs && node --check scripts/verify.mjs && node tests/selftest.mjs",
44
+ "test": "node --check doctor.mjs && node --check lib/framework.mjs && node --check lib/util.mjs && node --check lib/checks-package.mjs && node --check lib/checks-cordis.mjs && node --check lib/checks-smoke.mjs && node --check lib/checks-collections.mjs && node --check scripts/badge.mjs && node --check scripts/verify.mjs && node --check tests/selftest.mjs && node --check tests/contract.mjs && node tests/selftest.mjs && node tests/contract.mjs",
45
45
  "prepublishOnly": "npm test",
46
46
  "verify:registry": "node scripts/verify.mjs",
47
47
  "badge": "node scripts/badge.mjs"