@perrylink/dsh-plugin-doctor 0.1.7 → 0.2.1
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 +22 -0
- package/README.md +62 -14
- package/doctor.mjs +178 -38
- package/lib/checks-collections.mjs +15 -25
- package/lib/checks-cordis.mjs +26 -6
- package/lib/checks-package.mjs +14 -3
- package/lib/checks-smoke.mjs +43 -19
- package/lib/framework.mjs +42 -4
- package/lib/util.mjs +42 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-09-12
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Widen the stale-peer-range detector beyond the single `>=0.1.0-rc.N <0.2.0` shape it was written for, and stop claiming the `--dsh` default is "npm 已发布 latest" (npm `@deepseek-ai/dsh` latest is `0.1.5-rc.1`). The default itself is unchanged: 38 downstream CI gates rely on it as a peer-floor smoke.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
- **Silent-pass fixes (the load-bearing change)** — three ways this tool could return a green result without having actually decided anything are now closed:
|
|
12
|
+
- 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).
|
|
13
|
+
- `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.
|
|
14
|
+
- 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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **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] /`).
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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).
|
|
21
|
+
- **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.
|
|
22
|
+
- **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.)
|
|
23
|
+
- **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.
|
|
24
|
+
|
|
3
25
|
## 0.1.7
|
|
4
26
|
|
|
5
27
|
- **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
|
-
|
|
69
|
+
> ⚠️ 既有家族 37 仓的门禁**不读退出码**(workflow 用 `set +e` / `out="$(…)"` / `set -e`),只读 stdout 的 `R0 ` / `K1 ` 与 JSON 里 `results[].name` 前缀分流。因此 0.2.0 的退出码新增**对既有链路零影响**;它服务于交互式使用与未来的接入方。
|
|
45
70
|
|
|
46
|
-
- 冒烟全程使用 `%TEMP%`
|
|
47
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
179
|
+
npx --yes @perrylink/dsh-plugin-doctor@0.2.0 --repo . --no-smoke --only "R,K"
|
|
140
180
|
```
|
|
141
181
|
|
|
142
|
-
|
|
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,63 @@
|
|
|
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
|
|
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 =
|
|
30
|
+
--dsh <版本> 冒烟宿主版本(默认 ${'0.1.2-rc.1'} = peer 下限:刻意用最老的受支持线做
|
|
31
|
+
向后兼容冒烟,不是 npm latest —— npm @deepseek-ai/dsh 实测 latest 为
|
|
32
|
+
0.1.5-rc.1、next 为 0.1.5-rc.2。38 个下游仓的 plugin-doctor.yml 吃这个
|
|
33
|
+
默认值,改默认值会静默改变那些门禁的含义)
|
|
20
34
|
--only <分组> 只跑指定分组(逗号分隔)。推荐用 ASCII 别名(编码安全):
|
|
21
35
|
R = 静态·包结构
|
|
22
36
|
K = 静态·cordis 契约扫描
|
|
23
37
|
D = 动态·沙箱冒烟
|
|
24
38
|
CC = 生态·集合站清单
|
|
25
39
|
中文全名同样可用;大小写不敏感。
|
|
26
|
-
--
|
|
40
|
+
--allow-degraded 允许「被请求的组整组未真跑」时仍返回 0(默认返回 6)
|
|
41
|
+
--json <路径|-> 另存 JSON 报告;传 "-" 写 stdout(此时抑制人类可读报告)
|
|
42
|
+
--purge <隔离目录> 删除本工具自己产生的隔离目录(仅接受 ${QUARANTINE_PREFIX}* 前缀)
|
|
27
43
|
-h, --help 显示帮助
|
|
44
|
+
-v, --version 显示版本
|
|
45
|
+
|
|
46
|
+
退出码:
|
|
47
|
+
0 = 无 fail/error(可含 warn/skip)
|
|
48
|
+
1 = 存在 fail/error(插件缺陷)
|
|
49
|
+
2 = 用法错误、未知分组或未知选项
|
|
50
|
+
3 = 基础设施错误(如缺 npm/pnpm 等环境不可用)
|
|
51
|
+
4 = 不支持的宿主版本(宿主安装失败,不判插件)
|
|
52
|
+
5 = 结果不稳定(步骤超时)
|
|
53
|
+
6 = 降级:被请求的组整组未真跑(如无源文件可扫描)—— 不得当作通过
|
|
28
54
|
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
安全: 冒烟全程使用 %TEMP% 自建沙箱(前缀 doctor-,与宿主 %TEMP%\\dsh-* 保护模板不重叠),绝不触碰真实 ~/.dsh。
|
|
56
|
+
运行结束只做「隔离不删除」(rename 到 ${QUARANTINE_PREFIX}*),需人工确认后用 --purge 清理。
|
|
31
57
|
|
|
32
|
-
防静默通过: --only
|
|
33
|
-
|
|
58
|
+
防静默通过: --only 里的分组名只要有一个不匹配,本工具立即以退出码 2 失败。
|
|
59
|
+
被请求的分组若整组未真跑(全 skip),以退出码 6 失败——除非显式 --allow-degraded。`
|
|
34
60
|
|
|
35
|
-
// 分组 ASCII 别名(工作流里用它们可免受文件编码影响;中文全名仍可用)
|
|
36
61
|
const GROUP_ALIASES = {
|
|
37
62
|
R: '静态·包结构',
|
|
38
63
|
K: '静态·cordis 契约扫描',
|
|
@@ -40,40 +65,76 @@ const GROUP_ALIASES = {
|
|
|
40
65
|
CC: '生态·集合站清单',
|
|
41
66
|
}
|
|
42
67
|
|
|
68
|
+
const FLAGS_WITH_VALUE = new Set(['--repo', '-r', '--workspace', '-w', '--dsh', '--json', '--only', '--purge'])
|
|
69
|
+
|
|
43
70
|
function parseArgs(argv) {
|
|
44
|
-
const opts = {
|
|
71
|
+
const opts = {
|
|
72
|
+
repo: null, workspace: null, smoke: true, dshVersion: '0.1.2-rc.1', json: null,
|
|
73
|
+
groups: null, help: false, version: false, allowDegraded: false, purge: null,
|
|
74
|
+
}
|
|
45
75
|
for (let i = 0; i < argv.length; i++) {
|
|
46
76
|
const a = argv[i]
|
|
47
77
|
if (a === '--repo' || a === '-r') opts.repo = argv[++i]
|
|
78
|
+
else if (a === '--workspace' || a === '-w') opts.workspace = argv[++i]
|
|
48
79
|
else if (a === '--no-smoke') opts.smoke = false
|
|
49
80
|
else if (a === '--dsh') opts.dshVersion = argv[++i]
|
|
50
81
|
else if (a === '--json') opts.json = argv[++i]
|
|
51
|
-
else if (a === '--only') opts.groups = argv[++i].split(',').map((s) => s.trim())
|
|
82
|
+
else if (a === '--only') opts.groups = String(argv[++i] ?? '').split(',').map((s) => s.trim())
|
|
83
|
+
else if (a === '--purge') opts.purge = argv[++i]
|
|
84
|
+
else if (a === '--allow-degraded') opts.allowDegraded = true
|
|
52
85
|
else if (a === '--help' || a === '-h') opts.help = true
|
|
53
|
-
else if (
|
|
86
|
+
else if (a === '--version' || a === '-v') opts.version = true
|
|
87
|
+
else if (a.startsWith('-')) {
|
|
88
|
+
// 未知选项必须报错:旧实现会把未识别长选项的值当作位置参数、静默改写 --repo(静默错靶)
|
|
89
|
+
return { ...opts, error: `未知选项: ${a}` }
|
|
90
|
+
} else if (opts.repo === null) {
|
|
91
|
+
opts.repo = a
|
|
92
|
+
} else {
|
|
93
|
+
return { ...opts, error: `多余的位置参数: ${a}(--repo 已由前一个位置参数占用)` }
|
|
94
|
+
}
|
|
95
|
+
if (FLAGS_WITH_VALUE.has(a) && argv[i] === undefined) return { ...opts, error: `选项 ${a} 缺少取值` }
|
|
54
96
|
}
|
|
55
97
|
return opts
|
|
56
98
|
}
|
|
57
99
|
|
|
58
|
-
|
|
100
|
+
function main() {
|
|
59
101
|
const opts = parseArgs(process.argv.slice(2))
|
|
60
|
-
if (opts.help) { console.log(USAGE); return }
|
|
61
|
-
if (
|
|
102
|
+
if (opts.help) { console.log(USAGE); return 0 }
|
|
103
|
+
if (opts.version) { console.log(VERSION); return 0 }
|
|
104
|
+
if (opts.error) { console.error(`${opts.error}\n\n${USAGE}`); return 2 }
|
|
105
|
+
|
|
106
|
+
// --purge:三段式的第三段(人工确认后清理)。只接受本工具自己的隔离目录。
|
|
107
|
+
if (opts.purge) {
|
|
108
|
+
const abs = path.resolve(opts.purge)
|
|
109
|
+
if (!path.basename(abs).startsWith(QUARANTINE_PREFIX)) {
|
|
110
|
+
console.error(`拒绝清理:只接受 ${QUARANTINE_PREFIX}* 前缀的隔离目录(收到 ${path.basename(abs)})`)
|
|
111
|
+
return 2
|
|
112
|
+
}
|
|
113
|
+
try { rmSync(abs, { recursive: true, force: true }) } catch (e) { console.error(`清理失败: ${e.message}`); return 3 }
|
|
114
|
+
console.log(`已清理隔离目录: ${abs}`)
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!opts.repo) { console.error('缺少 --repo <路径>\n\n' + USAGE); return 2 }
|
|
62
119
|
const repoPath = path.resolve(opts.repo)
|
|
63
120
|
const pkgPath = path.join(repoPath, 'package.json')
|
|
64
|
-
if (!existsSync(pkgPath)) { console.error(`未找到 ${pkgPath}`);
|
|
121
|
+
if (!existsSync(pkgPath)) { console.error(`未找到 ${pkgPath}`); return 2 }
|
|
65
122
|
let pkg
|
|
66
|
-
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch (e) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const logDir =
|
|
123
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch (e) { console.error(`package.json 解析失败: ${e.message}`); return 2 }
|
|
124
|
+
|
|
125
|
+
const runRoot = mkdtempSync(path.join(tmpdir(), 'doctor-run-'))
|
|
126
|
+
const logDir = path.join(runRoot, 'logs')
|
|
127
|
+
mkdirSync(logDir, { recursive: true })
|
|
128
|
+
const workspaceRoot = opts.workspace ? path.resolve(opts.workspace) : path.resolve(import.meta.dirname, '..')
|
|
70
129
|
const ctx = {
|
|
71
130
|
repoPath,
|
|
72
131
|
pkg,
|
|
73
132
|
pkgName: pkg.name,
|
|
74
133
|
logDir,
|
|
75
|
-
workspaceRoot
|
|
134
|
+
workspaceRoot,
|
|
135
|
+
sandboxRoots: [runRoot],
|
|
76
136
|
}
|
|
137
|
+
|
|
77
138
|
const doctor = new Doctor()
|
|
78
139
|
addPackageChecks(doctor, ctx)
|
|
79
140
|
addCordisChecks(doctor, ctx)
|
|
@@ -82,34 +143,113 @@ async function main() {
|
|
|
82
143
|
|
|
83
144
|
const knownGroups = [...new Set(doctor.checks.map((c) => c.group))]
|
|
84
145
|
if (opts.groups) {
|
|
85
|
-
// 别名 → 分组全名;大小写不敏感。未命中的 token 原样保留,下面统一报错。
|
|
86
146
|
const resolved = opts.groups.map((t) => GROUP_ALIASES[t.toUpperCase()] ?? t)
|
|
87
147
|
const unknown = resolved.filter((g) => !knownGroups.includes(g))
|
|
88
148
|
if (unknown.length > 0) {
|
|
149
|
+
const smokeHint = !opts.smoke && unknown.some((g) => GROUP_ALIASES.D === g)
|
|
150
|
+
? '\n注:D 组需要动态冒烟,不能与 --no-smoke 同时请求(要么去掉 --no-smoke,要么从 --only 里去掉 D)'
|
|
151
|
+
: ''
|
|
89
152
|
console.error(
|
|
90
153
|
`未知分组: ${unknown.join(', ')}\n` +
|
|
91
|
-
`可用分组: ${knownGroups.map((g) => `${g}(${Object.keys(GROUP_ALIASES).find((a) => GROUP_ALIASES[a] === g) ?? '-'})`).join(', ')}`,
|
|
154
|
+
`可用分组: ${knownGroups.map((g) => `${g}(${Object.keys(GROUP_ALIASES).find((a) => GROUP_ALIASES[a] === g) ?? '-'})`).join(', ')}${smokeHint}`,
|
|
92
155
|
)
|
|
93
|
-
|
|
94
|
-
return
|
|
156
|
+
return 2
|
|
95
157
|
}
|
|
96
158
|
opts.groups = resolved
|
|
97
159
|
}
|
|
98
160
|
|
|
99
|
-
const
|
|
161
|
+
const started = Date.now()
|
|
162
|
+
const raw = doctor.run(ctx, { groups: opts.groups })
|
|
163
|
+
return Promise.resolve(raw).then((results) => finish(results, { opts, ctx, repoPath, pkg, runRoot, logDir, started }))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function finish(results, { opts, ctx, repoPath, pkg, runRoot, logDir, started }) {
|
|
100
167
|
if (results.length === 0) {
|
|
101
168
|
console.error('没有任何检查被执行(--only 未匹配到任何分组)——拒绝静默通过,退出码 2')
|
|
102
|
-
|
|
103
|
-
return
|
|
169
|
+
return 2
|
|
104
170
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
171
|
+
|
|
172
|
+
// 脱敏:把本次运行注入的绝对路径替换为占位符(R-fix 0C)
|
|
173
|
+
const redactPaths = [repoPath, logDir, runRoot, ...(ctx.sandboxRoots ?? [])]
|
|
174
|
+
for (const r of results) {
|
|
175
|
+
if (typeof r.message === 'string') r.message = redact(r.message, redactPaths)
|
|
176
|
+
if (typeof r.evidence === 'string') r.evidence = redact(r.evidence, redactPaths)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const groups = summarizeGroups(results)
|
|
180
|
+
const degraded = degradedGroups(groups)
|
|
181
|
+
const v = verdict(results, { degraded })
|
|
182
|
+
const durationMs = Date.now() - started
|
|
183
|
+
|
|
184
|
+
const envelope = {
|
|
185
|
+
schemaVersion: SCHEMA_VERSION,
|
|
186
|
+
doctorVersion: VERSION,
|
|
187
|
+
checksetVersion: CHECKSET,
|
|
188
|
+
target: {
|
|
189
|
+
repo: repoPath,
|
|
190
|
+
pkgName: pkg.name,
|
|
191
|
+
pkgVersion: pkg.version ?? null,
|
|
192
|
+
integrity: null, // 取自 registry(npm view <pkg>@<ver> dist.integrity);R/K 离线档不解析
|
|
193
|
+
integritySource: 'unresolved',
|
|
194
|
+
},
|
|
195
|
+
env: {
|
|
196
|
+
node: process.version,
|
|
197
|
+
platform: process.platform,
|
|
198
|
+
arch: process.arch,
|
|
199
|
+
hostDshVersion: opts.smoke ? opts.dshVersion : null,
|
|
200
|
+
smoke: opts.smoke,
|
|
201
|
+
},
|
|
202
|
+
startedAt: new Date(started).toISOString(),
|
|
203
|
+
durationMs,
|
|
204
|
+
quarantine: null,
|
|
205
|
+
coverage: ctx.coverage ?? {},
|
|
206
|
+
groups,
|
|
207
|
+
degraded,
|
|
208
|
+
verdict: { worst: v.worst, ok: v.ok, criticalFail: v.criticalFail },
|
|
209
|
+
results,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const qPath = quarantineSandbox({ root: runRoot })
|
|
213
|
+
envelope.quarantine = qPath
|
|
214
|
+
|
|
215
|
+
const code = exitCodeFor(results, { degraded, allowDegraded: opts.allowDegraded })
|
|
216
|
+
|
|
217
|
+
if (opts.json === '-') {
|
|
218
|
+
process.stdout.write(JSON.stringify(envelope, null, 2) + '\n')
|
|
219
|
+
} else {
|
|
220
|
+
console.log(`# dsh-plugin-doctor 报告\n目标: ${repoPath}\n包名: ${pkg.name}`)
|
|
221
|
+
console.log(render(results))
|
|
222
|
+
if (degraded.length) {
|
|
223
|
+
console.log(`\n[降级] 以下分组整组未真跑(全 skip),本次结论不可当作通过: ${degraded.join(', ')}`)
|
|
224
|
+
if (!opts.allowDegraded) console.log(' 如需显式接受,请加 --allow-degraded(退出码将降为 0)')
|
|
225
|
+
}
|
|
226
|
+
console.log(`沙箱隔离目录: ${qPath ?? '(未创建)'}(人工确认后用 --purge 清理)`)
|
|
227
|
+
if (opts.json) {
|
|
228
|
+
writeFileSync(opts.json, JSON.stringify(envelope, null, 2) + '\n', 'utf8')
|
|
229
|
+
console.log(`JSON 报告: ${opts.json}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return code
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 退出码映射:fail/error 优先(按 category 细分 3/4/5),其次「整组未真跑」→ 6。
|
|
236
|
+
function exitCodeFor(results, { degraded, allowDegraded }) {
|
|
237
|
+
const bad = results.filter((r) => r.status === 'fail' || r.status === 'error')
|
|
238
|
+
if (bad.length) {
|
|
239
|
+
const cats = [...new Set(bad.map((r) => r.category))]
|
|
240
|
+
if (cats.length === 1) {
|
|
241
|
+
if (cats[0] === 'unsupported-host') return 4
|
|
242
|
+
if (cats[0] === 'infrastructure') return 3
|
|
243
|
+
if (cats[0] === 'unstable') return 5
|
|
244
|
+
}
|
|
245
|
+
return 1
|
|
111
246
|
}
|
|
112
|
-
|
|
247
|
+
if (degraded.length && !allowDegraded) return 6
|
|
248
|
+
return 0
|
|
113
249
|
}
|
|
114
250
|
|
|
115
|
-
|
|
251
|
+
const out = main()
|
|
252
|
+
Promise.resolve(out).then((code) => { process.exitCode = typeof code === 'number' ? code : 0 }).catch((err) => {
|
|
253
|
+
console.error(err)
|
|
254
|
+
process.exitCode = 3
|
|
255
|
+
})
|
|
@@ -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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}
|
package/lib/checks-cordis.mjs
CHANGED
|
@@ -37,18 +37,38 @@ function readAll(files) {
|
|
|
37
37
|
|
|
38
38
|
const short = (f) => f.split(/[\\/]/).pop()
|
|
39
39
|
|
|
40
|
-
//
|
|
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
|
|
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))
|
|
53
|
+
if (/\.(mjs|js|cjs|ts|mts|cts)$/.test(entry.name)) srcFiles.push(path.join(repoPath, entry.name))
|
|
47
54
|
}
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/checks-package.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -168,8 +170,17 @@ export function addChecks(doctor, ctx) {
|
|
|
168
170
|
if (!peers.length) return skip('无 dsh 相关 peer 声明')
|
|
169
171
|
const problems = []
|
|
170
172
|
for (const [k, v] of peers) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
const s = String(v)
|
|
174
|
+
// Enumerating two literals (0.1.0-rc.8 / 0.1.1-rc.2) only caught the shapes
|
|
175
|
+
// that happened to exist on 2026-09-05; every other old line slipped through.
|
|
176
|
+
// Match the whole old-line family instead.
|
|
177
|
+
if (/(0\.1\.0-rc\.|0\.1\.1-rc\.|0\.1\.2-alpha\.|0\.1\.3-alpha\.)/.test(s)) problems.push(`${k}: ${s} 含旧 rc/alpha 版本(2026-09-05 起全仓统一为 >=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0)`)
|
|
178
|
+
// A single-arm prerelease-tuple range admits only the one prerelease it
|
|
179
|
+
// names: `>=0.1.2-rc.1 <0.2.0` (no `||`) rejects 0.1.5-rc.1 — exactly the
|
|
180
|
+
// false green the OR form exists to fix. Flag any bare arm.
|
|
181
|
+
if (!s.includes('||') && /^>=\s*0\.1\.\d+-[0-9A-Za-z.]+/.test(s) && /<\s*0\.2\.0/.test(s)) {
|
|
182
|
+
problems.push(`${k}: ${s} 是单臂 prerelease-tuple 区间,不会放行更新的宿主线(须写成 >=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0)`)
|
|
183
|
+
}
|
|
173
184
|
}
|
|
174
185
|
if (problems.length) return fail(problems.join('\n'))
|
|
175
186
|
return pass(peers.map(([k, v]) => `${k} ${v}`).join(';'))
|
package/lib/checks-smoke.mjs
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
// 动态·沙箱冒烟(D0–D3 +
|
|
1
|
+
// 动态·沙箱冒烟(D0–D3 + D9)
|
|
2
2
|
// 依据:dsh compat.yml 正典配方(MISSING_CREDENTIAL 判据)+ 2026-09-07 harness 调研 +
|
|
3
|
-
// 工作区红线 3
|
|
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,
|
|
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
|
-
|
|
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(
|
|
26
|
-
if (!exists(state.tgz)) return fail(`tarball 未生成: ${
|
|
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-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1
|
+
// 工具:临时沙箱 + 子进程执行(输出落盘,规避管道捕获限制)
|
|
2
2
|
import { spawnSync } from 'node:child_process'
|
|
3
3
|
import {
|
|
4
|
-
mkdtempSync, mkdirSync,
|
|
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(),
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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"
|