dsh-composition-doctor 0.1.3 → 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 ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Release the verified 0.2.0 package without changing the established diagnostic architecture or evidence boundaries.
6
+
7
+ ## 0.1.3
8
+
9
+ - Hardened the release package contract with the `dsh-doctor` binary entry.
10
+ - Added packed-package verification coverage and documented scan exit-code thresholds.
11
+ - Hardened npm artifact cache writes with integrity metadata and atomic temporary-file publication.
12
+ - Reject unsafe tar paths and link entries before reading package metadata.
13
+ - Kept DSH compatibility verified only for `0.1.5-rc.1` and `0.1.5-rc.2`; `0.1.6-alpha.1` remains expected-compatible only.
14
+ - Runtime smoke remains `not-run` unless a trusted OS-level isolation backend is available.
15
+ - Two Windows symlink regressions may remain `skipped` when the test process cannot create file symlinks; this is not a compatibility PASS.
16
+ - `0.1.6-alpha.1` is experimental/expected-compatible and is not part of the verified release range.
17
+ - A temporary directory is not a security sandbox; no runtime safety claim is made from temporary-directory execution.
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # dsh-composition-doctor
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/dsh-composition-doctor)](https://www.npmjs.com/package/dsh-composition-doctor)
4
- [![CI](https://github.com/lemonxiny55/dsh-composition-doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/lemonxiny55/dsh-composition-doctor/actions)
4
+ [![CI](https://github.com/lemonxiny55/dsh-composition-doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/lemonxiny55/dsh-composition-doctor/actions/workflows/ci.yml)
5
5
 
6
6
  English | [中文](README.zh.md)
7
7
 
8
8
  Composition and upgrade preflight doctor for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`). It reads an explicitly selected profile and explains observable Cordis/plugin composition risks with concrete evidence. It never edits a real profile or silently changes permissions.
9
9
 
10
+ Current package release: `0.2.0`.
11
+
10
12
  ## What the model gets
11
13
 
12
14
  | Command | Purpose |
@@ -22,18 +24,21 @@ The Web Settings page is display/export only: it reads the latest local report,
22
24
 
23
25
  `scan --output <dir>` writes only to the requested directory. To make the same report visible to the read-only Settings page, opt in explicitly: `--publish` copies it to the default plugin directory `.dsh-composition-doctor/reports`, while `--report-dir <dir>` publishes to a configured plugin directory. Use `--format both` so the Web route has `report.json` and Markdown remains exportable. Do not use a profile directory, `.env` location, or any directory containing keys, tokens, or other secrets as a report directory.
24
26
 
25
- Reports declare `evidenceMode`: `static` means allow-listed root YAML/manifest metadata only; `resolved` requires an injected public runtime provider; `mixed` is reserved for an adapter that supplies both. Static findings and the bounded metadata coverage are not proof of the final runtime composition. This release has no stable public DSH runtime provider bundled.
27
+ Reports declare `evidenceMode`: `static` means allow-listed manifest/patch/package metadata only; `composed` means a public `dsh --profile <name> --dump-config` command returned a composition result; `runtime-observed` is reserved for a successful isolated runtime observation backend; and `mixed` is reserved for an adapter that supplies both. `dump-config` never proves runtime execution, including when a `!!js` or other runtime-dependent value is present. Static findings and bounded metadata coverage are not proof of the final runtime composition. If no compatible public DSH CLI is available, scan falls back to static and records a warning. Reports written before evidence schema 2 may still be read as legacy `resolved`, but new reports never emit that label.
28
+
29
+ `preflight --candidate package@version` records a validated exact reference in an isolated temporary `package.json`. Target artifact discovery is local-first (`--dsh-bin`, package directory, tarball, installed `dsh`, `--package-manager-cache`, then Doctor-owned cache); registry access is allowed only with explicit `--online`. Online resolution downloads exact registry tarballs into Doctor-owned cache, records source/version/integrity/hash, and never installs or runs lifecycle scripts. `--allow-build` does not grant permission to execute third-party runtime code.
26
30
 
27
- `preflight --candidate package@version` inserts a validated exact reference into an isolated temporary `package.json` and includes that declared metadata in static analysis. It never downloads, installs, loads, or runs candidate lifecycle scripts; peer/platform facts inside an uninstalled candidate and runtime compatibility remain unverified. `--allow-build` is only a recorded future runner gate and still executes no third-party script.
31
+ `scan --fail-on never|info|warning|error` controls the scan exit code. The default is `never`: warnings and errors remain in the report without changing the exit code. `info` fails on any diagnostic, `warning` fails on warnings or errors, and `error` fails only on errors. Malformed arguments return exit code 2; operational failures return 1.
28
32
 
29
33
  ## Install
30
34
 
31
35
  ```powershell
32
36
  npm install -g dsh-composition-doctor
33
37
  npx @deepseek-ai/dsh plugin --profile web add dsh-composition-doctor
38
+ dsh-doctor --version
34
39
  ```
35
40
 
36
- Restart the Web UI (`npx @deepseek-ai/dsh web`) after changing a profile. The CLI can also run from a checkout with `node dist/cli/main.js`.
41
+ Restart the Web UI (`npx @deepseek-ai/dsh web`) after changing a profile.
37
42
 
38
43
  ## Example
39
44
 
@@ -42,18 +47,18 @@ dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\pr
42
47
  dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\archive --report-dir C:\safe\doctor-reports
43
48
  dsh-doctor snapshot --profile C:\path\to\profile --output .\reports\before.json
44
49
  dsh-doctor diff --before .\reports\before.json --after .\reports\after.json --format both
45
- dsh-doctor preflight --profile C:\path\to\profile --target-dsh 0.1.0-rc.6
50
+ dsh-doctor preflight --profile C:\path\to\profile --target-dsh 0.1.5-rc.2
46
51
  ```
47
52
 
48
- Each finding is `info`, `warning`, or `error` and includes evidence, explanation, and the smallest remediation. Missing runtime evidence is reported as a warning, never as a confirmed failure.
53
+ Each finding is `info`, `warning`, or `error` and includes evidence, explanation, and the smallest remediation. `runtimeSmoke.status=not-run` is not a runtime PASS; `artifact-unavailable` is not `incompatible`; and a warning is not a confirmed failure.
49
54
 
50
55
  ## Safety and privacy
51
56
 
52
- Default operations are read-only or isolated under the OS temporary directory. The plugin does not modify profiles, install/remove plugins, migrate configuration, escalate permissions, or perform network I/O by default. It never reads `.env`, keys, tokens, environment values, session bodies, or workspace file contents.
57
+ Default operations are read-only or isolated under the OS temporary directory. The plugin does not modify profiles, install/remove plugins, migrate configuration, escalate permissions, or perform network I/O by default. It never reads `.env`, profile secrets, session bodies, or workspace file contents, and does not collect or persist arbitrary environment-variable values. Public CLI execution receives only the minimal process-routing variables required by the host platform.
53
58
 
54
59
  ## Support and limitations
55
60
 
56
- Verified preview range: DSH `>=0.1.0-rc.5 <0.2.0`, Cordis `>=4 <5`, Node.js `>=20`; Windows is first-class and macOS/Linux are supported. DSH has not yet exposed a stable resolved-composition introspection API, so static findings are explicitly labelled when no public provider is available.
61
+ The real public CLI harness verifies `@deepseek-ai/dsh@0.1.5-rc.1` and `@deepseek-ai/dsh@0.1.5-rc.2`. `0.1.6-alpha.1` remains expected-compatible/experimental only; `0.1.0-rc.6` is historical context, not a current verified target. Missing artifacts are reported as unavailable, never as PASS. Verified development runtime: Node.js 24 on Windows; CI covers Node.js 20 on Ubuntu. Runtime hook/UI ownership is reported as unverified unless public metadata supplies it, and a temp profile is not a security sandbox.
57
62
 
58
63
  ## Development
59
64
 
@@ -63,4 +68,6 @@ pnpm typecheck
63
68
  pnpm build
64
69
  ```
65
70
 
71
+ For checkout-only development, run the CLI with `node dist/cli/main.js` after building.
72
+
66
73
  See [`README.zh.md`](README.zh.md), [`docs/compatibility.md`](docs/compatibility.md), and [`docs/examples/scan-report.md`](docs/examples/scan-report.md). MIT licensed.
package/README.zh.md CHANGED
@@ -1,36 +1,73 @@
1
1
  # dsh-composition-doctor(中文)
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/dsh-composition-doctor)](https://www.npmjs.com/package/dsh-composition-doctor)
4
+ [![CI](https://github.com/lemonxiny55/dsh-composition-doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/lemonxiny55/dsh-composition-doctor/actions/workflows/ci.yml)
5
+
3
6
  [English](README.md) | 中文
4
7
 
5
- 面向 DeepSeek Harness(DSH)的本地只读组合与升级预检插件。它读取明确指定的 profile,分析 Cordis/plugin 组合风险,生成脱敏快照,比较升级差异,并在独立临时目录中演练升级。
8
+ 面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)的组合与升级预检工具。它读取明确指定的 profile,用可追溯证据说明可观察到的 Cordis/plugin 组合风险;不会编辑真实 profile,也不会静默扩大权限。
9
+
10
+ 当前 package release:`0.2.0`。
6
11
 
7
- ## 功能
12
+ ## 模型可用能力
8
13
 
9
14
  | 命令 | 作用 |
10
15
  |---|---|
11
- | `dsh-doctor scan` | 检测重复 row、hook 顺序风险、UI slot/route 冲突、bundle 覆盖、peer/platform 不匹配和 profile 漂移。 |
12
- | `dsh-doctor snapshot` | 生成脱敏、可比较的 profile 快照和 lockfile 哈希。 |
13
- | `dsh-doctor diff` | 比较插件、rows、hooks、UIpeer 和平台变化。 |
14
- | `dsh-doctor preflight` | 在独立临时目录预演目标 DSH 升级。 |
16
+ | `dsh-doctor scan` | 检测重复 Cordis row、hook 顺序风险、UI slot/route 所有权冲突、bundle 覆盖、peer/platform 不匹配和 profile 漂移。 |
17
+ | `dsh-doctor snapshot` | 生成脱敏、可比较的 profile 快照及 lockfile 哈希。 |
18
+ | `dsh-doctor diff` | 汇总新增、删除或升级的插件,以及 rows、hooks、UI 声明、peer 和平台变化。 |
19
+ | `dsh-doctor preflight` | 在独立临时 profile 中演练目标 DSH 升级。 |
15
20
 
16
- Web Settings 只读显示最近报告、冲突图并导出 JSON/Markdown,不提供修复、安装或卸载操作。
21
+ Web Settings 页面仅供显示与导出:它读取最新本地报告、展示冲突图,并导出 JSON/Markdown;不提供修复、安装或卸载操作。
17
22
 
18
23
  ## 报告与证据边界
19
24
 
20
- `scan --output <目录>` 只写入该显式目录。要让只读 Settings 页面看到同一份报告,必须显式加 `--publish`,它会发布到插件默认目录 `.dsh-composition-doctor/reports`;或者用 `--report-dir <目录>` 发布到配置的目录。请使用 `--format both`,这样 Web route 可读取 `report.json`,同时保留 Markdown 导出。报告目录不得是 profile、`.env` 所在目录,或任何包含密钥、token 等敏感信息的目录。
25
+ `scan --output <目录>` 只写入显式指定的目录。要让只读 Settings 页面看到同一份报告,必须显式选择 `--publish`,它会复制到默认插件目录 `.dsh-composition-doctor/reports`;也可以使用 `--report-dir <目录>` 发布到配置的插件报告目录。请使用 `--format both`,使 Web route 能读取 `report.json`,同时保留 Markdown 导出。报告目录不能是 profile、`.env` 所在位置,或任何含密钥、token 或其他敏感信息的目录。
26
+
27
+ 报告中的 `evidenceMode` 标明证据来源:`static` 仅表示 allow-list 中的 manifest、patch、package metadata 等静态证据;`composed` 表示公开 `dsh --profile <name> --dump-config` 返回了 composition 结果;`runtime-observed` 只允许真正启动隔离 runtime 并观察到注册/行为的后端使用;`mixed` 预留给同时提供多类来源的适配器。`dump-config` 即使成功,也不代表 runtime 已验证;存在 `!!js` 等运行时依赖时尤其如此。找不到兼容的公开 DSH CLI 时会回退到 static,并明确报告 warning。旧的 evidence schema 2 之前报告仍可按 legacy `resolved` 读取,但新报告不会再输出该标签。
21
28
 
22
- 报告的 `evidenceMode` 表示证据来源:`static` 仅为允许列表中的根级 YAML/manifest 元数据;`resolved` 必须来自注入的公开 runtime provider;`mixed` 留给同时具有两类来源的适配器。静态发现和有限的 metadata coverage 不是最终 runtime composition 的证明;当前版本未内置稳定公开的 DSH runtime provider
29
+ `preflight --candidate package@version` 只会把通过校验的精确引用记录到隔离临时 `package.json`。目标 artifact 默认按本地优先顺序查找:`--dsh-bin`、本地 package directory、本地 tarball、已安装 `dsh`、`--package-manager-cache`、Doctor 自己的 cache;只有显式 `--online` 才允许访问 registry。在线解析会把精确 tarball 写入 Doctor 自己的 cache,记录来源、版本、integrity hash,并且不会安装或执行 lifecycle script。`--allow-build` 不等于允许执行第三方 runtime code
23
30
 
24
- `preflight --candidate package@version` 会将合法的精确引用放入隔离临时 `package.json`,使其声明元数据参与静态分析。它不会下载、安装、加载候选包,也不会执行第三方生命周期脚本;未安装候选包内部的 peer/platform 元数据与 runtime 兼容性仍未验证。`--allow-build` 目前仅记录未来 runner 的显式门槛,仍不会执行任何第三方脚本。
31
+ `scan --fail-on never|info|warning|error` 控制 scan 的退出码。默认是 `never`:warning error 会写入报告,但不改变退出码;`info` 遇到任意诊断时退出 1,`warning` 遇到 warning 或 error 时退出 1,`error` 只在 error 时退出 1。参数错误退出 2,执行失败退出 1。
25
32
 
26
- ## 安装与使用
33
+ ## 安装
27
34
 
28
35
  ```powershell
29
36
  npm install -g dsh-composition-doctor
30
37
  npx @deepseek-ai/dsh plugin --profile web add dsh-composition-doctor
38
+ dsh-doctor --version
39
+ ```
40
+
41
+ 更改 profile 后重启 Web UI:`npx @deepseek-ai/dsh web`。
42
+
43
+ ## 示例
44
+
45
+ ```powershell
31
46
  dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\profile --publish
47
+ dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\archive --report-dir C:\safe\doctor-reports
48
+ dsh-doctor snapshot --profile C:\path\to\profile --output .\reports\before.json
49
+ dsh-doctor diff --before .\reports\before.json --after .\reports\after.json --format both
50
+ dsh-doctor preflight --profile C:\path\to\profile --target-dsh 0.1.5-rc.2
51
+ ```
52
+
53
+ 每条诊断均为 `info`、`warning` 或 `error`,并附带 evidence、explanation 和最小 remediation。`runtimeSmoke.status=not-run` 不等于 runtime PASS;`artifact-unavailable` 不等于 `incompatible`;warning 也不等于已确认失败。
54
+
55
+ ## 安全与隐私
56
+
57
+ 默认操作只读,或仅在操作系统临时目录中隔离运行。插件不会修改 profile、安装或删除插件、迁移配置、扩大权限,默认也不会执行网络 I/O。它不会读取 `.env`、profile 密钥、会话正文或工作区源文件内容,也不会收集或持久化任意环境变量值;启动公开 CLI 时只传递平台所需的最小进程路由变量。
58
+
59
+ ## 支持范围与限制
60
+
61
+ real-release harness 已使用真实公开 CLI 验证 `@deepseek-ai/dsh@0.1.5-rc.1` 和 `@deepseek-ai/dsh@0.1.5-rc.2`。`0.1.6-alpha.1` 仍只是 expected-compatible/experimental;`0.1.0-rc.6` 仅作历史兼容背景,不是当前 verified target。artifact 不可用时会明确报告 unavailable,绝不记为 PASS。已验证开发环境为 Windows + Node.js 24;CI 覆盖 Ubuntu + Node.js 20。只有公开 metadata 能提供 hook/UI ownership 时才会确认,否则标为 unverified;临时目录不是安全 sandbox。
62
+
63
+ ## 开发
64
+
65
+ ```powershell
66
+ pnpm test
67
+ pnpm typecheck
68
+ pnpm build
32
69
  ```
33
70
 
34
- 默认操作只读或写入操作系统临时目录,不修改真实 profile,不扩大权限,不默认联网,不读取 `.env`、密钥、token、环境变量值、会话正文或工作区文件内容。证据不足时只报告 warning
71
+ 仅在 checkout 开发时,构建完成后使用 `node dist/cli/main.js` 运行 CLI
35
72
 
36
- 已验证范围:DSH `>=0.1.0-rc.5 <0.2.0`、Cordis `>=4 <5`、Node.js `>=20`;Windows 优先,同时支持 macOS/Linux
73
+ 参阅 [`README.md`](README.md)、[`docs/compatibility.md`](docs/compatibility.md) [`docs/examples/scan-report.md`](docs/examples/scan-report.md)MIT 许可。
package/dist/cli/main.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { fileURLToPath } from 'node:url';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
4
  import { dirname, resolve } from 'node:path';
4
5
  import { resolveComposition } from '../core/composition-adapter.js';
5
6
  import { diffSnapshots, renderSnapshotDiffMarkdown } from '../core/diff.js';
6
7
  import { readProfile } from '../core/profile-reader.js';
7
- import { createSnapshot } from '../core/snapshot.js';
8
+ import { createSnapshot, isSnapshotV1, isSnapshotV2 } from '../core/snapshot.js';
8
9
  import { analyseComposition } from '../core/rules.js';
9
10
  import { renderJson } from '../reports/json.js';
10
11
  import { renderMarkdown } from '../reports/markdown.js';
@@ -12,6 +13,15 @@ import { resolveReportDirectory } from '../reports/location.js';
12
13
  import { runPreflight } from '../core/preflight.js';
13
14
  const usage = 'Usage: dsh-doctor <scan | snapshot | diff | preflight>';
14
15
  const commands = new Set(['scan', 'snapshot', 'diff', 'preflight']);
16
+ function packageVersion() {
17
+ try {
18
+ const manifest = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../../package.json'), 'utf8'));
19
+ return typeof manifest.version === 'string' ? manifest.version : 'unknown';
20
+ }
21
+ catch {
22
+ return 'unknown';
23
+ }
24
+ }
15
25
  function option(argv, name) {
16
26
  const index = argv.indexOf(name);
17
27
  return index >= 0 && typeof argv[index + 1] === 'string' ? argv[index + 1] : undefined;
@@ -38,7 +48,16 @@ async function scan(argv, io) {
38
48
  return invalid(io, 'scan requires --profile and --output');
39
49
  if (format !== 'json' && format !== 'markdown' && format !== 'both')
40
50
  return invalid(io, '--format must be json, markdown, or both');
41
- const report = analyseComposition(await resolveComposition(await readProfile({ profileDir: resolve(profile) })));
51
+ const failOn = option(argv, '--fail-on') ?? 'never';
52
+ if (failOn !== 'never' && failOn !== 'info' && failOn !== 'warning' && failOn !== 'error')
53
+ return invalid(io, '--fail-on must be info, warning, error, or never');
54
+ const primary = await resolveComposition(await readProfile({ profileDir: resolve(profile) }));
55
+ const compared = await Promise.all(options(argv, '--compare-profile').map(async (value) => ({ name: value, model: await resolveComposition(await readProfile({ profileDir: resolve(value) })) })));
56
+ const report = analyseComposition(compared.length === 0 ? primary : {
57
+ ...primary,
58
+ bundles: [...(primary.bundles ?? []), ...compared.flatMap(({ name, model }) => (model.bundles ?? []).map((bundle) => ({ ...bundle, profile: name })))],
59
+ adapterDiagnostics: [...primary.adapterDiagnostics, ...compared.flatMap(({ model }) => model.adapterDiagnostics)]
60
+ });
42
61
  const destination = resolve(output);
43
62
  const destinations = [destination, ...(publish ? [resolveReportDirectory(configuredReportDir)] : [])];
44
63
  for (const directory of destinations) {
@@ -51,6 +70,12 @@ async function scan(argv, io) {
51
70
  io.write(`Wrote reports to ${destination}`);
52
71
  if (publish)
53
72
  io.write(`Published reports to ${destinations[1]}`);
73
+ if (failOn === 'info' && report.diagnostics.length > 0)
74
+ return 1;
75
+ if (failOn === 'error' && report.diagnostics.some((item) => item.severity === 'error'))
76
+ return 1;
77
+ if (failOn === 'warning' && report.diagnostics.some((item) => item.severity === 'warning' || item.severity === 'error'))
78
+ return 1;
54
79
  return 0;
55
80
  }
56
81
  async function snapshot(argv, io) {
@@ -64,9 +89,7 @@ async function snapshot(argv, io) {
64
89
  io.write(`Wrote snapshot to ${destination}`);
65
90
  return 0;
66
91
  }
67
- function isSnapshot(value) {
68
- return value !== null && typeof value === 'object' && value.schemaVersion === 1;
69
- }
92
+ function isSnapshot(value) { return isSnapshotV1(value) || isSnapshotV2(value); }
70
93
  async function diff(argv, io) {
71
94
  const beforePath = option(argv, '--before');
72
95
  const afterPath = option(argv, '--after');
@@ -78,12 +101,21 @@ async function diff(argv, io) {
78
101
  const before = JSON.parse(await readFile(resolve(beforePath), 'utf8'));
79
102
  const after = JSON.parse(await readFile(resolve(afterPath), 'utf8'));
80
103
  if (!isSnapshot(before) || !isSnapshot(after))
81
- return invalid(io, 'diff inputs must be schema version 1 snapshots');
104
+ return invalid(io, 'diff inputs must be schema version 1 or 2 snapshots');
82
105
  const result = diffSnapshots(before, after);
83
- if (format === 'json' || format === 'both')
84
- io.write(`${JSON.stringify(result, null, 2)}\n`);
85
- if (format === 'markdown' || format === 'both')
86
- io.write(renderSnapshotDiffMarkdown(result));
106
+ const output = option(argv, '--output');
107
+ const rendered = [
108
+ ...(format === 'json' || format === 'both' ? [`${JSON.stringify(result, null, 2)}\n`] : []),
109
+ ...(format === 'markdown' || format === 'both' ? [renderSnapshotDiffMarkdown(result)] : [])
110
+ ];
111
+ if (output === undefined)
112
+ rendered.forEach((value) => io.write(value));
113
+ else {
114
+ const destination = resolve(output);
115
+ await mkdir(dirname(destination), { recursive: true });
116
+ await writeFile(destination, rendered.join(''), 'utf8');
117
+ io.write(`Wrote diff to ${destination}`);
118
+ }
87
119
  return 0;
88
120
  }
89
121
  async function preflight(argv, io) {
@@ -98,7 +130,10 @@ async function preflight(argv, io) {
98
130
  candidates: options(argv, '--candidate'),
99
131
  allowBuild: argv.includes('--allow-build'),
100
132
  online: argv.includes('--online'),
101
- keepTemp: argv.includes('--keep-temp')
133
+ keepTemp: argv.includes('--keep-temp'),
134
+ dshBin: option(argv, '--dsh-bin'),
135
+ dshPackage: option(argv, '--dsh-package'),
136
+ packageManagerCacheDirs: options(argv, '--package-manager-cache')
102
137
  });
103
138
  const rendered = `${JSON.stringify(result, null, 2)}\n`;
104
139
  if (output === undefined)
@@ -117,6 +152,10 @@ export async function runCli(argv, io = { write: (line) => console.log(line) })
117
152
  io.write(usage);
118
153
  return 0;
119
154
  }
155
+ if (command === '--version') {
156
+ io.write(packageVersion());
157
+ return 0;
158
+ }
120
159
  if (!commands.has(command)) {
121
160
  io.write(`Unknown command: ${command}`);
122
161
  return 2;
@@ -8,7 +8,7 @@ export const registration = Object.freeze({
8
8
  });
9
9
  export const apply = Object.assign((ctx) => {
10
10
  const messages = {
11
- zh: { title: 'DSH 组合医生', exportJson: '导出 JSON', exportMarkdown: '导出 Markdown' },
11
+ zh: { title: 'DSH Composition Doctor', exportJson: '导出 JSON', exportMarkdown: '导出 Markdown' },
12
12
  en: { title: 'DSH Composition Doctor', exportJson: 'Export JSON', exportMarkdown: 'Export Markdown' }
13
13
  };
14
14
  const disposeLocale = ctx.locale?.register('dshCompositionDoctor', messages);
@@ -2,6 +2,19 @@ import { createElement, useEffect, useRef } from 'react';
2
2
  import { renderMarkdown } from '../reports/markdown.js';
3
3
  export const clientReportPath = '/dsh-composition-doctor/reports/latest';
4
4
  export const downloadRevokeDelayMs = 1000;
5
+ export function evidenceBadgeLabel(mode, runtimeObserved) {
6
+ if (mode === 'runtime-observed' && runtimeObserved)
7
+ return 'runtime-observed';
8
+ if (mode === 'runtime-observed')
9
+ return 'composed; runtime not observed';
10
+ if (mode === 'composed')
11
+ return 'composed; runtime not observed';
12
+ if (mode === 'mixed')
13
+ return runtimeObserved ? 'mixed; runtime observed' : 'mixed; runtime not observed';
14
+ if (mode === 'resolved')
15
+ return 'legacy resolved; runtime not observed';
16
+ return 'static';
17
+ }
5
18
  export function buildConflictGraph(report) {
6
19
  const nodes = new Map();
7
20
  const edges = [];
@@ -24,6 +37,28 @@ export function toReportViewModel(report) {
24
37
  counts[diagnostic.severity] += 1;
25
38
  return { report, counts, graph: buildConflictGraph(report) };
26
39
  }
40
+ function renderConflictGraph(document, graph) {
41
+ const section = document.createElement('section');
42
+ section.setAttribute('aria-labelledby', 'dsh-composition-doctor-conflict-graph');
43
+ const heading = document.createElement('h3');
44
+ heading.id = 'dsh-composition-doctor-conflict-graph';
45
+ heading.textContent = 'Conflict graph';
46
+ section.append(heading);
47
+ const list = document.createElement('ul');
48
+ for (const node of graph.nodes) {
49
+ const item = document.createElement('li');
50
+ const outgoing = graph.edges.filter((edge) => edge.from === node.id).map((edge) => graph.nodes.find((candidate) => candidate.id === edge.to)?.label ?? edge.to);
51
+ item.textContent = `${node.label} (${node.source})${outgoing.length === 0 ? '' : ` → ${outgoing.join(', ')}`}`;
52
+ list.append(item);
53
+ }
54
+ if (graph.nodes.length === 0) {
55
+ const item = document.createElement('li');
56
+ item.textContent = 'No evidence relationships were produced.';
57
+ list.append(item);
58
+ }
59
+ section.append(list);
60
+ return section;
61
+ }
27
62
  function isReport(value) {
28
63
  if (value === null || typeof value !== 'object')
29
64
  return false;
@@ -101,9 +136,15 @@ export function createReportView(document = globalThis.document, translate = (ke
101
136
  const actions = document.createElement('p');
102
137
  actions.append(button(document, translate('exportJson'), () => { void exportReport(document, 'json', dependencies).catch(() => undefined); }), text(document, ' '), button(document, translate('exportMarkdown'), () => { void exportReport(document, 'markdown', dependencies).catch(() => undefined); }));
103
138
  root.append(actions);
104
- void fetchReport().then((report) => {
139
+ void fetchReport(dependencies.fetcher).then((report) => {
105
140
  const model = toReportViewModel(report);
106
- status.textContent = `Generated: ${report.generatedAt}. Profile: ${report.profileDir}. Evidence mode: ${report.evidenceMode}. Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info.`;
141
+ const badge = document.createElement('span');
142
+ badge.dataset.evidenceKind = report.evidenceMode;
143
+ badge.setAttribute('aria-label', 'Evidence capability');
144
+ badge.textContent = `Evidence: ${evidenceBadgeLabel(report.evidenceMode, report.runtimeObserved === true)}`;
145
+ status.textContent = `Generated: ${report.generatedAt}. Profile: ${report.profileDir}. Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info.`;
146
+ status.append(document.createTextNode(' '), badge);
147
+ root.append(renderConflictGraph(document, model.graph));
107
148
  const list = document.createElement('ul');
108
149
  for (const diagnostic of report.diagnostics) {
109
150
  const item = document.createElement('li');
package/dist/client.js CHANGED
@@ -41,7 +41,7 @@ window.__ModuleLoader__.load({
41
41
  function formatEvidence(item) {
42
42
  const subject = item.subject === void 0 ? "" : ` \u2014 ${item.subject}`;
43
43
  const packageLabel = item.packageName === void 0 ? "" : ` (${item.packageName}${item.version === void 0 ? "" : `@${item.version}`})`;
44
- return `- \`${item.source}\`${subject}${packageLabel}: ${item.detail}`;
44
+ return `- [${item.evidenceKind}] \`${item.source}\`${subject}${packageLabel}: ${item.detail}`;
45
45
  }
46
46
  function formatDiagnostic(item) {
47
47
  const evidence = item.evidence.length === 0 ? "- No concrete source evidence was supplied." : item.evidence.map(formatEvidence).join("\n");
@@ -62,13 +62,13 @@ window.__ModuleLoader__.load({
62
62
  `;
63
63
  return `# DSH Composition Doctor Report
64
64
 
65
- Schema: ${report.schemaVersion}
65
+ Schema: ${report.schemaVersion}; evidence schema: ${report.evidenceSchemaVersion}
66
66
 
67
67
  Profile: \`${report.profileDir}\`
68
68
 
69
69
  Generated: ${report.generatedAt}
70
70
 
71
- Evidence mode: ${report.evidenceMode}
71
+ Evidence mode: ${report.evidenceMode}; runtime observed: ${report.runtimeObserved ? "yes" : "no"}
72
72
  ${coverage}
73
73
  Summary: ${summary}
74
74
 
@@ -79,6 +79,14 @@ window.__ModuleLoader__.load({
79
79
  // src/client/report-view.ts
80
80
  var clientReportPath = "/dsh-composition-doctor/reports/latest";
81
81
  var downloadRevokeDelayMs = 1e3;
82
+ function evidenceBadgeLabel(mode, runtimeObserved) {
83
+ if (mode === "runtime-observed" && runtimeObserved) return "runtime-observed";
84
+ if (mode === "runtime-observed") return "composed; runtime not observed";
85
+ if (mode === "composed") return "composed; runtime not observed";
86
+ if (mode === "mixed") return runtimeObserved ? "mixed; runtime observed" : "mixed; runtime not observed";
87
+ if (mode === "resolved") return "legacy resolved; runtime not observed";
88
+ return "static";
89
+ }
82
90
  function buildConflictGraph(report) {
83
91
  const nodes = /* @__PURE__ */ new Map();
84
92
  const edges = [];
@@ -100,6 +108,28 @@ window.__ModuleLoader__.load({
100
108
  for (const diagnostic of report.diagnostics) counts[diagnostic.severity] += 1;
101
109
  return { report, counts, graph: buildConflictGraph(report) };
102
110
  }
111
+ function renderConflictGraph(document, graph) {
112
+ const section = document.createElement("section");
113
+ section.setAttribute("aria-labelledby", "dsh-composition-doctor-conflict-graph");
114
+ const heading = document.createElement("h3");
115
+ heading.id = "dsh-composition-doctor-conflict-graph";
116
+ heading.textContent = "Conflict graph";
117
+ section.append(heading);
118
+ const list = document.createElement("ul");
119
+ for (const node of graph.nodes) {
120
+ const item = document.createElement("li");
121
+ const outgoing = graph.edges.filter((edge) => edge.from === node.id).map((edge) => graph.nodes.find((candidate) => candidate.id === edge.to)?.label ?? edge.to);
122
+ item.textContent = `${node.label} (${node.source})${outgoing.length === 0 ? "" : ` \u2192 ${outgoing.join(", ")}`}`;
123
+ list.append(item);
124
+ }
125
+ if (graph.nodes.length === 0) {
126
+ const item = document.createElement("li");
127
+ item.textContent = "No evidence relationships were produced.";
128
+ list.append(item);
129
+ }
130
+ section.append(list);
131
+ return section;
132
+ }
103
133
  function isReport(value) {
104
134
  if (value === null || typeof value !== "object") return false;
105
135
  const candidate = value;
@@ -181,9 +211,15 @@ window.__ModuleLoader__.load({
181
211
  })
182
212
  );
183
213
  root.append(actions);
184
- void fetchReport().then((report) => {
214
+ void fetchReport(dependencies.fetcher).then((report) => {
185
215
  const model = toReportViewModel(report);
186
- status.textContent = `Generated: ${report.generatedAt}. Profile: ${report.profileDir}. Evidence mode: ${report.evidenceMode}. Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info.`;
216
+ const badge = document.createElement("span");
217
+ badge.dataset.evidenceKind = report.evidenceMode;
218
+ badge.setAttribute("aria-label", "Evidence capability");
219
+ badge.textContent = `Evidence: ${evidenceBadgeLabel(report.evidenceMode, report.runtimeObserved === true)}`;
220
+ status.textContent = `Generated: ${report.generatedAt}. Profile: ${report.profileDir}. Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info.`;
221
+ status.append(document.createTextNode(" "), badge);
222
+ root.append(renderConflictGraph(document, model.graph));
187
223
  const list = document.createElement("ul");
188
224
  for (const diagnostic of report.diagnostics) {
189
225
  const item = document.createElement("li");
@@ -233,7 +269,7 @@ window.__ModuleLoader__.load({
233
269
  var apply = Object.assign(
234
270
  (ctx) => {
235
271
  const messages = {
236
- zh: { title: "DSH \u7EC4\u5408\u533B\u751F", exportJson: "\u5BFC\u51FA JSON", exportMarkdown: "\u5BFC\u51FA Markdown" },
272
+ zh: { title: "DSH Composition Doctor", exportJson: "\u5BFC\u51FA JSON", exportMarkdown: "\u5BFC\u51FA Markdown" },
237
273
  en: { title: "DSH Composition Doctor", exportJson: "Export JSON", exportMarkdown: "Export Markdown" }
238
274
  };
239
275
  const disposeLocale = ctx.locale?.register("dshCompositionDoctor", messages);
@@ -0,0 +1,66 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { inspectInstalledPackage } from './package-provenance.js';
3
+ function record(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
5
+ }
6
+ function diagnostic(id, title, source, detail) {
7
+ return {
8
+ id,
9
+ severity: 'warning',
10
+ title,
11
+ evidence: [{ source, detail, evidenceKind: 'static' }],
12
+ explanation: detail,
13
+ remediation: 'Install the declared bundle in the selected profile or provide the missing public manifest/patch metadata.'
14
+ };
15
+ }
16
+ function bundleNames(manifest) {
17
+ const dsh = record(manifest.dsh);
18
+ const profile = record(dsh?.profile);
19
+ const bundles = profile?.bundles;
20
+ if (!Array.isArray(bundles))
21
+ return [];
22
+ return bundles.flatMap((entry) => {
23
+ if (typeof entry === 'string')
24
+ return [{ name: entry }];
25
+ const object = record(entry);
26
+ const name = typeof object?.name === 'string' ? object.name : typeof object?.package === 'string' ? object.package : undefined;
27
+ if (name === undefined)
28
+ return [];
29
+ return [{ name, ...(typeof object?.version === 'string' ? { requestedSpec: object.version } : {}) }];
30
+ });
31
+ }
32
+ export async function inspectBundleInventory(profileDir, packageJsonText) {
33
+ let manifest;
34
+ try {
35
+ manifest = JSON.parse(packageJsonText);
36
+ }
37
+ catch {
38
+ return { packages: [], diagnostics: [diagnostic('bundle-inventory-manifest-invalid', 'Profile package manifest is invalid', 'package.json', 'The profile package.json could not be parsed, so dsh.profile.bundles cannot be inspected.')] };
39
+ }
40
+ const packages = [];
41
+ const diagnostics = [];
42
+ for (const declared of bundleNames(manifest)) {
43
+ try {
44
+ const fact = await inspectInstalledPackage(profileDir, declared.name, declared.requestedSpec);
45
+ packages.push(fact);
46
+ if (fact.bundlePatch !== undefined) {
47
+ try {
48
+ await access(fact.bundlePatch);
49
+ }
50
+ catch {
51
+ diagnostics.push(diagnostic('bundle-patch-missing', 'Bundle patch metadata is missing', fact.packageJsonSource, `${declared.name} declares dsh.bundle.patch at ${fact.bundlePatch}, but that file is not readable.`));
52
+ }
53
+ }
54
+ else {
55
+ diagnostics.push(diagnostic('bundle-patch-unavailable', 'Bundle patch metadata is unavailable', fact.packageJsonSource, `${declared.name} has no readable dsh.bundle.patch declaration.`));
56
+ }
57
+ if (fact.integrity === undefined && fact.gitRef === undefined) {
58
+ diagnostics.push(diagnostic('bundle-provenance-unavailable', 'Bundle immutable provenance is unavailable', fact.packageJsonSource, `${declared.name} has neither registry integrity nor a fixed Git reference in its readable manifest.`));
59
+ }
60
+ }
61
+ catch (error) {
62
+ diagnostics.push(diagnostic('bundle-not-installed', 'Declared bundle is not installed or readable', 'package.json', `${declared.name}${declared.requestedSpec === undefined ? '' : `@${declared.requestedSpec}`} could not be resolved from the selected profile: ${error instanceof Error ? error.message : String(error)}`));
63
+ }
64
+ }
65
+ return { packages, diagnostics };
66
+ }