dsh-composition-doctor 0.1.0 → 0.1.3

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/README.md CHANGED
@@ -1,63 +1,59 @@
1
1
  # dsh-composition-doctor
2
2
 
3
- DeepSeek Harness (DSH) 的本地组合与升级预检插件。它只读取用户明确指定的 profile 元数据,输出有证据的 Cordis/插件组合诊断,并在独立临时目录中演练升级;不会修改真实 profile、安装或卸载插件,也不会扩大权限。
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
5
 
5
- ## 中文
6
+ English | [中文](README.zh.md)
6
7
 
7
- ### 功能
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.
8
9
 
9
- - `scan`:分析显式 profile composition,输出 JSON/Markdown。
10
- - `snapshot`:生成脱敏、可比较的 profile 快照和 lockfile 哈希。
11
- - `diff`:比较两个快照,识别插件升降级、rows、hooks、UI、peer/platform 变化。
12
- - `preflight`:在 OS 临时目录中进行离线解析和最小 smoke test;没有 `--allow-build` 时只输出构建计划。
13
- - DSH Settings 页面:只读显示最近报告、冲突关系和本地导出按钮。
10
+ ## What the model gets
14
11
 
15
- ### 安装与卸载
12
+ | Command | Purpose |
13
+ |---|---|
14
+ | `dsh-doctor scan` | Detect duplicate Cordis rows, hook-order risks, UI slot/route ownership conflicts, bundle overrides, peer/platform mismatches, and profile drift. |
15
+ | `dsh-doctor snapshot` | Create a redacted, comparable profile snapshot with lockfile hashes. |
16
+ | `dsh-doctor diff` | Summarize added/removed/upgraded plugins, rows, hooks, UI claims, peers, and platforms. |
17
+ | `dsh-doctor preflight` | Rehearse a target DSH upgrade in an isolated temporary profile. |
16
18
 
17
- ```powershell
18
- pnpm install
19
- pnpm build
20
- dsh plugin --profile <profile> add C:\path\to\dsh-composition-doctor
21
- dsh plugin --profile <profile> remove dsh-composition-doctor
22
- ```
19
+ The Web Settings page is display/export only: it reads the latest local report, shows a conflict graph, and exports JSON/Markdown. It has no repair, install, or uninstall action.
23
20
 
24
- 命令行也可直接运行 `node dist/cli/main.js`(或 `pnpm dsh-doctor`)。
21
+ ## Reports and evidence boundaries
25
22
 
26
- ### 使用
23
+ `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.
27
24
 
28
- ```powershell
29
- dsh-doctor scan --profile .\test\fixtures\healthy --format both --output .\reports\healthy
30
- dsh-doctor snapshot --profile .\test\fixtures\healthy --output .\reports\before.json
31
- dsh-doctor diff --before .\reports\before.json --after .\reports\after.json --format both
32
- dsh-doctor preflight --profile .\test\fixtures\preflight-real-profile --target-dsh 0.1.0-rc.6 --candidate example-plugin@2.0.0
33
- ```
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.
34
26
 
35
- `--online` 只记录用户明确选择在线适配器的意图;当前实现仍不调用网络。`--allow-build` 是显式闸门,当前版本没有第三方构建 runner,因此不会执行生命周期脚本。
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.
36
28
 
37
- ### 输出示例
29
+ ## Install
38
30
 
39
- ```text
40
- warning runtime-composition-unavailable
41
- evidence: cordis.yml (static)
42
- explanation: 未注入公开的 resolved-composition provider,静态 YAML 不能证明运行时加载结果。
43
- remediation: 在隔离 fixture 中提供公开 provider 后重跑。
31
+ ```powershell
32
+ npm install -g dsh-composition-doctor
33
+ npx @deepseek-ai/dsh plugin --profile web add dsh-composition-doctor
44
34
  ```
45
35
 
46
- 完整示例见 [`docs/examples/scan-report.md`](docs/examples/scan-report.md)。
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`.
47
37
 
48
- ### 权限与隐私边界
38
+ ## Example
49
39
 
50
- 默认只读,只读取显式 profile 根目录的 `cordis.yml`、`cordis.patch.yml`、`package.json` 和 lockfile 元数据,并拒绝符号链接。不会读取 `.env`、密钥、token、环境变量值、会话正文或工作区文件内容,不联网,不安装/卸载/迁移,不切换权限模式。快照递归脱敏敏感字段并只保留结构摘要与 SHA-256。工具不提供“安全评分”,也不声称插件本身安全。
40
+ ```powershell
41
+ dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\profile --publish
42
+ dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\archive --report-dir C:\safe\doctor-reports
43
+ dsh-doctor snapshot --profile C:\path\to\profile --output .\reports\before.json
44
+ 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
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
49
 
54
- 当前验证过的开发预览范围:DSH `>=0.1.0-rc.5 <0.2.0`、Cordis `>=4 <5`、Node `>=20`,Windows 优先且可在 macOS/Linux 使用。DSH 尚未公开稳定的 resolved composition introspection API;未注入 provider 时结果明确标记为 static,不能证明启动成功或失败。Settings 页面依赖公开 `settings.plugins.tab` slot,不使用私有图标或 loader API。
50
+ ## Safety and privacy
55
51
 
56
- ## English
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
53
 
58
- `dsh-composition-doctor` is a local, read-only composition and upgrade preflight plugin for DeepSeek Harness. It analyses an explicitly selected profile, reports evidence-backed Cordis and plugin risks, snapshots redacted metadata, diffs snapshots, and rehearses upgrades in an isolated temporary directory. It never edits a real profile, installs or removes plugins, changes permissions, reads `.env`/keys/tokens/session/workspace contents, or performs network I/O by default. Findings are `info`, `warning`, or `error`; incomplete evidence is a warning, not a confirmed failure.
54
+ ## Support and limitations
59
55
 
60
- Commands are `dsh-doctor scan`, `snapshot`, `diff`, and `preflight`; invocation examples are above. The Web Settings section is display/export only. Verified preview range: DSH `>=0.1.0-rc.5 <0.2.0`, Cordis `>=4 <5`, Node `>=20`. Runtime composition introspection is an injected public-provider boundary; static findings are labelled accordingly. See [`docs/compatibility.md`](docs/compatibility.md) and [`docs/examples/scan-report.md`](docs/examples/scan-report.md).
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
57
 
62
58
  ## Development
63
59
 
@@ -67,4 +63,4 @@ pnpm typecheck
67
63
  pnpm build
68
64
  ```
69
65
 
70
- Fixtures under `test/fixtures` cover healthy, duplicate row id, hook order, UI conflict, peer mismatch, redaction, and preflight isolation cases.
66
+ 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 ADDED
@@ -0,0 +1,36 @@
1
+ # dsh-composition-doctor(中文)
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 面向 DeepSeek Harness(DSH)的本地只读组合与升级预检插件。它读取明确指定的 profile,分析 Cordis/plugin 组合风险,生成脱敏快照,比较升级差异,并在独立临时目录中演练升级。
6
+
7
+ ## 功能
8
+
9
+ | 命令 | 作用 |
10
+ |---|---|
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、UI、peer 和平台变化。 |
14
+ | `dsh-doctor preflight` | 在独立临时目录预演目标 DSH 升级。 |
15
+
16
+ Web Settings 只读显示最近报告、冲突图并导出 JSON/Markdown,不提供修复、安装或卸载操作。
17
+
18
+ ## 报告与证据边界
19
+
20
+ `scan --output <目录>` 只写入该显式目录。要让只读 Settings 页面看到同一份报告,必须显式加 `--publish`,它会发布到插件默认目录 `.dsh-composition-doctor/reports`;或者用 `--report-dir <目录>` 发布到配置的目录。请使用 `--format both`,这样 Web route 可读取 `report.json`,同时保留 Markdown 导出。报告目录不得是 profile、`.env` 所在目录,或任何包含密钥、token 等敏感信息的目录。
21
+
22
+ 报告的 `evidenceMode` 表示证据来源:`static` 仅为允许列表中的根级 YAML/manifest 元数据;`resolved` 必须来自注入的公开 runtime provider;`mixed` 留给同时具有两类来源的适配器。静态发现和有限的 metadata coverage 不是最终 runtime composition 的证明;当前版本未内置稳定公开的 DSH runtime provider。
23
+
24
+ `preflight --candidate package@version` 会将合法的精确引用放入隔离临时 `package.json`,使其声明元数据参与静态分析。它不会下载、安装、加载候选包,也不会执行第三方生命周期脚本;未安装候选包内部的 peer/platform 元数据与 runtime 兼容性仍未验证。`--allow-build` 目前仅记录未来 runner 的显式门槛,仍不会执行任何第三方脚本。
25
+
26
+ ## 安装与使用
27
+
28
+ ```powershell
29
+ npm install -g dsh-composition-doctor
30
+ npx @deepseek-ai/dsh plugin --profile web add dsh-composition-doctor
31
+ dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\profile --publish
32
+ ```
33
+
34
+ 默认操作只读或写入操作系统临时目录,不修改真实 profile,不扩大权限,不默认联网,不读取 `.env`、密钥、token、环境变量值、会话正文或工作区文件内容。证据不足时只报告 warning。
35
+
36
+ 已验证范围:DSH `>=0.1.0-rc.5 <0.2.0`、Cordis `>=4 <5`、Node.js `>=20`;Windows 优先,同时支持 macOS/Linux。
package/dist/cli/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
- import { resolve } from 'node:path';
3
+ import { dirname, resolve } from 'node:path';
4
4
  import { resolveComposition } from '../core/composition-adapter.js';
5
5
  import { diffSnapshots, renderSnapshotDiffMarkdown } from '../core/diff.js';
6
6
  import { readProfile } from '../core/profile-reader.js';
@@ -8,6 +8,7 @@ import { createSnapshot } from '../core/snapshot.js';
8
8
  import { analyseComposition } from '../core/rules.js';
9
9
  import { renderJson } from '../reports/json.js';
10
10
  import { renderMarkdown } from '../reports/markdown.js';
11
+ import { resolveReportDirectory } from '../reports/location.js';
11
12
  import { runPreflight } from '../core/preflight.js';
12
13
  const usage = 'Usage: dsh-doctor <scan | snapshot | diff | preflight>';
13
14
  const commands = new Set(['scan', 'snapshot', 'diff', 'preflight']);
@@ -30,6 +31,8 @@ function invalid(io, message) {
30
31
  async function scan(argv, io) {
31
32
  const profile = option(argv, '--profile');
32
33
  const output = option(argv, '--output');
34
+ const configuredReportDir = option(argv, '--report-dir');
35
+ const publish = argv.includes('--publish') || configuredReportDir !== undefined;
33
36
  const format = option(argv, '--format') ?? 'json';
34
37
  if (profile === undefined || output === undefined)
35
38
  return invalid(io, 'scan requires --profile and --output');
@@ -37,12 +40,17 @@ async function scan(argv, io) {
37
40
  return invalid(io, '--format must be json, markdown, or both');
38
41
  const report = analyseComposition(await resolveComposition(await readProfile({ profileDir: resolve(profile) })));
39
42
  const destination = resolve(output);
40
- await mkdir(destination, { recursive: true });
41
- if (format === 'json' || format === 'both')
42
- await writeFile(resolve(destination, 'report.json'), renderJson(report), 'utf8');
43
- if (format === 'markdown' || format === 'both')
44
- await writeFile(resolve(destination, 'report.md'), renderMarkdown(report), 'utf8');
43
+ const destinations = [destination, ...(publish ? [resolveReportDirectory(configuredReportDir)] : [])];
44
+ for (const directory of destinations) {
45
+ await mkdir(directory, { recursive: true });
46
+ if (format === 'json' || format === 'both')
47
+ await writeFile(resolve(directory, 'report.json'), renderJson(report), 'utf8');
48
+ if (format === 'markdown' || format === 'both')
49
+ await writeFile(resolve(directory, 'report.md'), renderMarkdown(report), 'utf8');
50
+ }
45
51
  io.write(`Wrote reports to ${destination}`);
52
+ if (publish)
53
+ io.write(`Published reports to ${destinations[1]}`);
46
54
  return 0;
47
55
  }
48
56
  async function snapshot(argv, io) {
@@ -51,6 +59,7 @@ async function snapshot(argv, io) {
51
59
  if (profile === undefined || output === undefined)
52
60
  return invalid(io, 'snapshot requires --profile and --output');
53
61
  const destination = resolve(output);
62
+ await mkdir(dirname(destination), { recursive: true });
54
63
  await writeFile(destination, `${JSON.stringify(await createSnapshot(await readProfile({ profileDir: resolve(profile) })), null, 2)}\n`, 'utf8');
55
64
  io.write(`Wrote snapshot to ${destination}`);
56
65
  return 0;
@@ -96,7 +105,7 @@ async function preflight(argv, io) {
96
105
  io.write(rendered);
97
106
  else {
98
107
  const destination = resolve(output);
99
- await mkdir(resolve(destination, '..'), { recursive: true });
108
+ await mkdir(dirname(destination), { recursive: true });
100
109
  await writeFile(destination, rendered, 'utf8');
101
110
  io.write(`Wrote preflight report to ${destination}`);
102
111
  }
@@ -1,9 +1,10 @@
1
- import { createReportView } from './report-view.js';
1
+ import { createElement } from 'react';
2
+ import { ReportView } from './report-view.js';
2
3
  export const name = 'dsh-composition-doctor';
3
4
  export const registration = Object.freeze({
4
5
  id: name,
5
6
  actions: ['export-json', 'export-markdown'],
6
- component: () => createReportView()
7
+ component: () => createElement(ReportView, { translate: (key) => key })
7
8
  });
8
9
  export const apply = Object.assign((ctx) => {
9
10
  const messages = {
@@ -15,7 +16,7 @@ export const apply = Object.assign((ctx) => {
15
16
  const disposeSlot = ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
16
17
  name: 'settings.plugins.tab', id: registration.id, order: 70,
17
18
  locale: 'dshCompositionDoctor', label: () => t('title')
18
- }, () => createReportView(undefined, t)));
19
+ }, () => createElement(ReportView, { translate: t })));
19
20
  return () => {
20
21
  if (typeof disposeSlot === 'function')
21
22
  disposeSlot();
@@ -1,5 +1,7 @@
1
+ import { createElement, useEffect, useRef } from 'react';
1
2
  import { renderMarkdown } from '../reports/markdown.js';
2
3
  export const clientReportPath = '/dsh-composition-doctor/reports/latest';
4
+ export const downloadRevokeDelayMs = 1000;
3
5
  export function buildConflictGraph(report) {
4
6
  const nodes = new Map();
5
7
  const edges = [];
@@ -47,12 +49,37 @@ async function fetchReport(fetcher = globalThis.fetch) {
47
49
  throw new Error('The latest report has an unsupported schema.');
48
50
  return value;
49
51
  }
50
- function download(document, content, filename, mime) {
52
+ const browserDownloadHooks = {
53
+ createObjectURL: (blob) => URL.createObjectURL(blob),
54
+ revokeObjectURL: (url) => URL.revokeObjectURL(url),
55
+ schedule: (callback, delay) => globalThis.setTimeout(callback, delay)
56
+ };
57
+ /** Create a local browser download without sending report data anywhere. */
58
+ export function download(document, content, filename, mime, hooks = browserDownloadHooks) {
59
+ const blob = new Blob([content], { type: mime });
51
60
  const link = document.createElement('a');
52
- link.href = URL.createObjectURL(new Blob([content], { type: mime }));
61
+ const objectUrl = hooks.createObjectURL(blob);
62
+ link.hidden = true;
63
+ link.href = objectUrl;
53
64
  link.download = filename;
54
- link.click();
55
- URL.revokeObjectURL(link.href);
65
+ document.body.append(link);
66
+ try {
67
+ link.click();
68
+ }
69
+ finally {
70
+ link.remove();
71
+ hooks.schedule(() => hooks.revokeObjectURL(objectUrl), downloadRevokeDelayMs);
72
+ }
73
+ }
74
+ export async function exportReport(document, format, dependencies = {}) {
75
+ const report = await fetchReport(dependencies.fetcher);
76
+ if (format === 'json') {
77
+ ;
78
+ (dependencies.download ?? download)(document, `${JSON.stringify(report, null, 2)}\n`, 'dsh-composition-doctor-report.json', 'application/json');
79
+ return;
80
+ }
81
+ ;
82
+ (dependencies.download ?? download)(document, renderMarkdown(report), 'dsh-composition-doctor-report.md', 'text/markdown');
56
83
  }
57
84
  /**
58
85
  * Browser-native settings section. It only performs a GET and offers local
@@ -60,7 +87,7 @@ function download(document, content, filename, mime) {
60
87
  */
61
88
  export function createReportView(document = globalThis.document, translate = (key) => ({
62
89
  title: 'DSH Composition Doctor', exportJson: 'Export JSON', exportMarkdown: 'Export Markdown', loading: 'Loading the latest local report…', unavailable: 'The latest report is unavailable.'
63
- }[key] ?? key)) {
90
+ }[key] ?? key), dependencies = {}) {
64
91
  const root = document.createElement('section');
65
92
  root.dataset.plugin = nameForDom;
66
93
  root.setAttribute('aria-labelledby', 'dsh-composition-doctor-title');
@@ -72,21 +99,52 @@ export function createReportView(document = globalThis.document, translate = (ke
72
99
  status.textContent = translate('loading');
73
100
  root.append(status);
74
101
  const actions = document.createElement('p');
75
- actions.append(button(document, translate('exportJson'), () => { void fetchReport().then((report) => download(document, `${JSON.stringify(report, null, 2)}\n`, 'dsh-composition-doctor-report.json', 'application/json')).catch(() => undefined); }), text(document, ' '), button(document, translate('exportMarkdown'), () => { void fetchReport().then((report) => download(document, renderMarkdown(report), 'dsh-composition-doctor-report.md', 'text/markdown')).catch(() => undefined); }));
102
+ 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); }));
76
103
  root.append(actions);
77
104
  void fetchReport().then((report) => {
78
105
  const model = toReportViewModel(report);
79
- status.textContent = `Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info. Conflict graph: ${model.graph.nodes.length} evidence nodes.`;
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.`;
80
107
  const list = document.createElement('ul');
81
108
  for (const diagnostic of report.diagnostics) {
82
109
  const item = document.createElement('li');
83
- item.textContent = `[${diagnostic.severity}] ${diagnostic.title}`;
110
+ const details = document.createElement('details');
111
+ const summary = document.createElement('summary');
112
+ summary.textContent = `[${diagnostic.severity}] ${diagnostic.title}`;
113
+ details.append(summary);
114
+ const explanation = document.createElement('p');
115
+ explanation.textContent = diagnostic.explanation;
116
+ const evidence = document.createElement('pre');
117
+ evidence.textContent = diagnostic.evidence.map((entry) => `${entry.source}: ${entry.detail}`).join('\n') || 'No concrete evidence.';
118
+ const remediation = document.createElement('p');
119
+ remediation.textContent = `Remediation: ${diagnostic.remediation}`;
120
+ details.append(explanation, evidence, remediation);
121
+ item.append(details);
84
122
  list.append(item);
85
123
  }
86
124
  root.append(list);
87
- }).catch((error) => {
88
- status.textContent = error instanceof Error ? error.message : translate('unavailable');
125
+ }).catch(() => {
126
+ status.textContent = translate('unavailable');
89
127
  });
90
128
  return root;
91
129
  }
130
+ /**
131
+ * DSH settings slots are rendered by React. Keep the browser-native report
132
+ * view as the implementation detail, but mount it through a React component
133
+ * rather than returning an HTMLElement directly to the slot renderer.
134
+ */
135
+ export function ReportView({ translate }) {
136
+ const host = useRef(null);
137
+ useEffect(() => {
138
+ const container = host.current;
139
+ if (container === null)
140
+ return;
141
+ const view = createReportView(globalThis.document, translate);
142
+ container.replaceChildren(view);
143
+ return () => {
144
+ view.remove();
145
+ container.replaceChildren();
146
+ };
147
+ }, [translate]);
148
+ return createElement('div', { className: 'dsh-composition-doctor-report-view', ref: host });
149
+ }
92
150
  const nameForDom = 'dsh-composition-doctor';
package/dist/client.js CHANGED
@@ -32,6 +32,10 @@ window.__ModuleLoader__.load({
32
32
  registration: () => registration
33
33
  });
34
34
  module.exports = __toCommonJS(index_exports);
35
+ var import_react2 = require("react");
36
+
37
+ // src/client/report-view.ts
38
+ var import_react = require("react");
35
39
 
36
40
  // src/reports/markdown.ts
37
41
  function formatEvidence(item) {
@@ -53,6 +57,9 @@ window.__ModuleLoader__.load({
53
57
  function renderMarkdown(report) {
54
58
  const summary = ["error", "warning", "info"].map((severity) => `${severity}: ${report.diagnostics.filter((item) => item.severity === severity).length}`).join(", ");
55
59
  const body = report.diagnostics.length === 0 ? "No diagnostics were produced." : report.diagnostics.map(formatDiagnostic).join("\n\n");
60
+ const coverage = report.metadataCoverage === void 0 ? "" : `
61
+ Metadata coverage: ${report.metadataCoverage.mode}; unscanned: ${report.metadataCoverage.unscannedSurfaces.join("; ")}
62
+ `;
56
63
  return `# DSH Composition Doctor Report
57
64
 
58
65
  Schema: ${report.schemaVersion}
@@ -61,6 +68,8 @@ window.__ModuleLoader__.load({
61
68
 
62
69
  Generated: ${report.generatedAt}
63
70
 
71
+ Evidence mode: ${report.evidenceMode}
72
+ ${coverage}
64
73
  Summary: ${summary}
65
74
 
66
75
  ${body}
@@ -69,6 +78,7 @@ window.__ModuleLoader__.load({
69
78
 
70
79
  // src/client/report-view.ts
71
80
  var clientReportPath = "/dsh-composition-doctor/reports/latest";
81
+ var downloadRevokeDelayMs = 1e3;
72
82
  function buildConflictGraph(report) {
73
83
  const nodes = /* @__PURE__ */ new Map();
74
84
  const edges = [];
@@ -112,12 +122,36 @@ window.__ModuleLoader__.load({
112
122
  if (!isReport(value)) throw new Error("The latest report has an unsupported schema.");
113
123
  return value;
114
124
  }
115
- function download(document, content, filename, mime) {
125
+ var browserDownloadHooks = {
126
+ createObjectURL: (blob) => URL.createObjectURL(blob),
127
+ revokeObjectURL: (url) => URL.revokeObjectURL(url),
128
+ schedule: (callback, delay) => globalThis.setTimeout(callback, delay)
129
+ };
130
+ function download(document, content, filename, mime, hooks = browserDownloadHooks) {
131
+ const blob = new Blob([content], { type: mime });
116
132
  const link = document.createElement("a");
117
- link.href = URL.createObjectURL(new Blob([content], { type: mime }));
133
+ const objectUrl = hooks.createObjectURL(blob);
134
+ link.hidden = true;
135
+ link.href = objectUrl;
118
136
  link.download = filename;
119
- link.click();
120
- URL.revokeObjectURL(link.href);
137
+ document.body.append(link);
138
+ try {
139
+ link.click();
140
+ } finally {
141
+ link.remove();
142
+ hooks.schedule(() => hooks.revokeObjectURL(objectUrl), downloadRevokeDelayMs);
143
+ }
144
+ }
145
+ async function exportReport(document, format, dependencies = {}) {
146
+ const report = await fetchReport(dependencies.fetcher);
147
+ if (format === "json") {
148
+ ;
149
+ (dependencies.download ?? download)(document, `${JSON.stringify(report, null, 2)}
150
+ `, "dsh-composition-doctor-report.json", "application/json");
151
+ return;
152
+ }
153
+ ;
154
+ (dependencies.download ?? download)(document, renderMarkdown(report), "dsh-composition-doctor-report.md", "text/markdown");
121
155
  }
122
156
  function createReportView(document = globalThis.document, translate = (key) => ({
123
157
  title: "DSH Composition Doctor",
@@ -125,7 +159,7 @@ window.__ModuleLoader__.load({
125
159
  exportMarkdown: "Export Markdown",
126
160
  loading: "Loading the latest local report\u2026",
127
161
  unavailable: "The latest report is unavailable."
128
- })[key] ?? key) {
162
+ })[key] ?? key, dependencies = {}) {
129
163
  const root = document.createElement("section");
130
164
  root.dataset.plugin = nameForDom;
131
165
  root.setAttribute("aria-labelledby", "dsh-composition-doctor-title");
@@ -139,30 +173,54 @@ window.__ModuleLoader__.load({
139
173
  const actions = document.createElement("p");
140
174
  actions.append(
141
175
  button(document, translate("exportJson"), () => {
142
- void fetchReport().then((report) => download(document, `${JSON.stringify(report, null, 2)}
143
- `, "dsh-composition-doctor-report.json", "application/json")).catch(() => void 0);
176
+ void exportReport(document, "json", dependencies).catch(() => void 0);
144
177
  }),
145
178
  text(document, " "),
146
179
  button(document, translate("exportMarkdown"), () => {
147
- void fetchReport().then((report) => download(document, renderMarkdown(report), "dsh-composition-doctor-report.md", "text/markdown")).catch(() => void 0);
180
+ void exportReport(document, "markdown", dependencies).catch(() => void 0);
148
181
  })
149
182
  );
150
183
  root.append(actions);
151
184
  void fetchReport().then((report) => {
152
185
  const model = toReportViewModel(report);
153
- status.textContent = `Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info. Conflict graph: ${model.graph.nodes.length} evidence nodes.`;
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.`;
154
187
  const list = document.createElement("ul");
155
188
  for (const diagnostic of report.diagnostics) {
156
189
  const item = document.createElement("li");
157
- item.textContent = `[${diagnostic.severity}] ${diagnostic.title}`;
190
+ const details = document.createElement("details");
191
+ const summary = document.createElement("summary");
192
+ summary.textContent = `[${diagnostic.severity}] ${diagnostic.title}`;
193
+ details.append(summary);
194
+ const explanation = document.createElement("p");
195
+ explanation.textContent = diagnostic.explanation;
196
+ const evidence = document.createElement("pre");
197
+ evidence.textContent = diagnostic.evidence.map((entry) => `${entry.source}: ${entry.detail}`).join("\n") || "No concrete evidence.";
198
+ const remediation = document.createElement("p");
199
+ remediation.textContent = `Remediation: ${diagnostic.remediation}`;
200
+ details.append(explanation, evidence, remediation);
201
+ item.append(details);
158
202
  list.append(item);
159
203
  }
160
204
  root.append(list);
161
- }).catch((error) => {
162
- status.textContent = error instanceof Error ? error.message : translate("unavailable");
205
+ }).catch(() => {
206
+ status.textContent = translate("unavailable");
163
207
  });
164
208
  return root;
165
209
  }
210
+ function ReportView({ translate }) {
211
+ const host = (0, import_react.useRef)(null);
212
+ (0, import_react.useEffect)(() => {
213
+ const container = host.current;
214
+ if (container === null) return;
215
+ const view = createReportView(globalThis.document, translate);
216
+ container.replaceChildren(view);
217
+ return () => {
218
+ view.remove();
219
+ container.replaceChildren();
220
+ };
221
+ }, [translate]);
222
+ return (0, import_react.createElement)("div", { className: "dsh-composition-doctor-report-view", ref: host });
223
+ }
166
224
  var nameForDom = "dsh-composition-doctor";
167
225
 
168
226
  // src/client/index.ts
@@ -170,7 +228,7 @@ window.__ModuleLoader__.load({
170
228
  var registration = Object.freeze({
171
229
  id: name,
172
230
  actions: ["export-json", "export-markdown"],
173
- component: () => createReportView()
231
+ component: () => (0, import_react2.createElement)(ReportView, { translate: (key) => key })
174
232
  });
175
233
  var apply = Object.assign(
176
234
  (ctx) => {
@@ -186,7 +244,7 @@ window.__ModuleLoader__.load({
186
244
  order: 70,
187
245
  locale: "dshCompositionDoctor",
188
246
  label: () => t("title")
189
- }, () => createReportView(void 0, t)));
247
+ }, () => (0, import_react2.createElement)(ReportView, { translate: t })));
190
248
  return () => {
191
249
  if (typeof disposeSlot === "function") disposeSlot();
192
250
  if (typeof disposeLocale === "function") disposeLocale();
@@ -66,18 +66,49 @@ function staticRows(file) {
66
66
  return [row];
67
67
  });
68
68
  }
69
+ function staticManifestFacts(file) {
70
+ if (file.relativePath !== 'package.json')
71
+ return {};
72
+ try {
73
+ const manifest = JSON.parse(file.text);
74
+ const dependencies = manifest.dependencies !== null && typeof manifest.dependencies === 'object' ? manifest.dependencies : {};
75
+ const bundles = Object.entries(dependencies)
76
+ .filter((entry) => typeof entry[1] === 'string')
77
+ .map(([name, version]) => ({ name, version, source: file.relativePath, evidenceKind: 'static' }));
78
+ const peer = manifest.peerDependencies !== null && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {};
79
+ const peerRequirements = typeof manifest.name === 'string' ? [{
80
+ packageName: manifest.name, source: file.relativePath, evidenceKind: 'static',
81
+ ...(typeof peer.dsh === 'string' ? { dsh: peer.dsh } : {}),
82
+ ...(typeof peer.cordis === 'string' ? { cordis: peer.cordis } : {}),
83
+ ...(typeof peer.node === 'string' ? { node: peer.node } : {})
84
+ }] : [];
85
+ const supported = Array.isArray(manifest.os) ? manifest.os.filter((value) => typeof value === 'string') : [];
86
+ const platforms = typeof manifest.name === 'string' && supported.length > 0 ? [{ packageName: manifest.name, source: file.relativePath, evidenceKind: 'static', supported }] : [];
87
+ return { bundles, peerRequirements, platforms };
88
+ }
89
+ catch {
90
+ return {};
91
+ }
92
+ }
69
93
  export async function resolveComposition(input, provider) {
70
94
  if (provider) {
71
95
  const providedRows = await provider.resolve(input);
72
96
  return {
73
97
  profileDir: input.profileDir,
74
98
  rows: providedRows.map((row) => ({ ...cloneProviderValue(row), evidenceKind: 'resolved' })),
75
- adapterDiagnostics: []
99
+ adapterDiagnostics: [], evidenceMode: 'resolved', metadataCoverage: input.metadataCoverage
76
100
  };
77
101
  }
78
102
  const rows = [];
103
+ const bundles = [];
104
+ const peerRequirements = [];
105
+ const platforms = [];
79
106
  const adapterDiagnostics = [runtimeUnavailableDiagnostic()];
80
107
  for (const file of input.files) {
108
+ const facts = staticManifestFacts(file);
109
+ bundles.push(...(facts.bundles ?? []));
110
+ peerRequirements.push(...(facts.peerRequirements ?? []));
111
+ platforms.push(...(facts.platforms ?? []));
81
112
  if (file.relativePath !== 'cordis.yml' && file.relativePath !== 'cordis.patch.yml')
82
113
  continue;
83
114
  try {
@@ -87,5 +118,5 @@ export async function resolveComposition(input, provider) {
87
118
  adapterDiagnostics.push(parseFailedDiagnostic(file, error));
88
119
  }
89
120
  }
90
- return { profileDir: input.profileDir, rows, adapterDiagnostics };
121
+ return { profileDir: input.profileDir, rows, bundles, peerRequirements, platforms, adapterDiagnostics, evidenceMode: 'static', metadataCoverage: input.metadataCoverage };
91
122
  }
package/dist/core/diff.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { satisfies as semverSatisfies } from 'semver';
1
2
  function formatChange(change) {
2
3
  const before = change.before === undefined ? '' : ` before=${JSON.stringify(change.before)}`;
3
4
  const after = change.after === undefined ? '' : ` after=${JSON.stringify(change.after)}`;
@@ -20,19 +21,22 @@ export function renderSnapshotDiffMarkdown(diff) {
20
21
  function compareVersions(left, right) {
21
22
  if (left === undefined || right === undefined)
22
23
  return undefined;
23
- const parse = (value) => /^(\d+)\.(\d+)\.(\d+)/.exec(value)?.slice(1).map(Number);
24
- const a = parse(left);
25
- const b = parse(right);
26
- if (a === undefined || b === undefined)
24
+ try {
25
+ if (semverSatisfies(left, `<${right}`, { includePrerelease: true }))
26
+ return -1;
27
+ if (semverSatisfies(left, `>${right}`, { includePrerelease: true }))
28
+ return 1;
29
+ if (semverSatisfies(left, `=${right}`, { includePrerelease: true }))
30
+ return 0;
27
31
  return undefined;
28
- for (let index = 0; index < 3; index += 1)
29
- if (a[index] !== b[index])
30
- return a[index] < b[index] ? -1 : 1;
31
- return 0;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
32
36
  }
33
- function changes(before, after, key) {
34
- const previous = new Map(before.map((item) => [key(item), item]));
35
- const next = new Map(after.map((item) => [key(item), item]));
37
+ function changes(before, after, beforeKey, afterKey = beforeKey) {
38
+ const previous = new Map(before.map((item) => [beforeKey(item), item]));
39
+ const next = new Map(after.map((item) => [afterKey(item), item]));
36
40
  const result = [];
37
41
  for (const itemKey of [...new Set([...previous.keys(), ...next.keys()])].sort()) {
38
42
  const left = previous.get(itemKey);
@@ -47,11 +51,13 @@ function changes(before, after, key) {
47
51
  return result;
48
52
  }
49
53
  export function diffSnapshots(before, after) {
50
- const pluginChanges = changes(before.plugins, after.plugins, (item) => item.name).map((change) => {
54
+ const pluginKey = (profile, item) => `${profile.path}:${profile.packageName ?? ''}:${item.name}:${item.source}`;
55
+ const pluginChanges = changes(before.plugins, after.plugins, (item) => pluginKey(before.profile, item), (item) => pluginKey(after.profile, item)).map((change) => {
56
+ const name = change.before?.name ?? change.after?.name ?? change.key;
51
57
  if (change.kind !== 'changed')
52
- return { ...change, name: change.key };
58
+ return { ...change, name };
53
59
  const comparison = compareVersions(change.before?.version, change.after?.version);
54
- return { ...change, name: change.key, kind: comparison === undefined ? 'changed' : comparison < 0 ? 'upgraded' : comparison > 0 ? 'downgraded' : 'changed' };
60
+ return { ...change, name, kind: comparison === undefined ? 'changed' : comparison < 0 ? 'upgraded' : comparison > 0 ? 'downgraded' : 'changed' };
55
61
  });
56
62
  const rowChanges = changes(before.rows, after.rows, (item) => `${item.source}:${item.id ?? item.name ?? ''}`);
57
63
  const hookChanges = changes(before.hooks, after.hooks, (item) => `${item.source}:${item.hook}:${item.packageName ?? ''}`);
@@ -30,6 +30,10 @@ function safeCandidate(value) {
30
30
  const version = value.slice(at + 1);
31
31
  return packageNamePattern.test(name) && versionPattern.test(version);
32
32
  }
33
+ function candidateParts(value) {
34
+ const at = value.lastIndexOf('@');
35
+ return { name: value.slice(0, at), version: value.slice(at + 1) };
36
+ }
33
37
  function safeMetadataText(relativePath, text) {
34
38
  try {
35
39
  if (relativePath === 'package.json') {
@@ -45,7 +49,8 @@ function safeMetadataText(relativePath, text) {
45
49
  }
46
50
  return undefined;
47
51
  }
48
- async function prepareIsolatedProfile(input, directory, evidenceItems) {
52
+ async function prepareIsolatedProfile(input, directory, candidates, evidenceItems) {
53
+ let wroteManifest = false;
49
54
  for (const file of input.files) {
50
55
  if (lockFiles.has(file.relativePath)) {
51
56
  evidenceItems.push(evidence(file.relativePath, 'lockfile-not-copied', 'Lockfile content is excluded from the rehearsal directory; its source hash remains available in snapshots.'));
@@ -56,7 +61,28 @@ async function prepareIsolatedProfile(input, directory, evidenceItems) {
56
61
  evidenceItems.push(evidence(file.relativePath, 'metadata-not-copied', 'Metadata was not copied because it could not be safely parsed.'));
57
62
  continue;
58
63
  }
64
+ if (file.relativePath === 'package.json' && candidates.length > 0) {
65
+ const manifest = JSON.parse(safeText);
66
+ const dependencies = manifest.dependencies !== null && typeof manifest.dependencies === 'object' ? manifest.dependencies : {};
67
+ for (const candidate of candidates) {
68
+ const { name, version } = candidateParts(candidate);
69
+ dependencies[name] = version;
70
+ }
71
+ manifest.dependencies = dependencies;
72
+ await writeFile(join(directory, file.relativePath), `${JSON.stringify(manifest, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
73
+ wroteManifest = true;
74
+ continue;
75
+ }
59
76
  await writeFile(join(directory, file.relativePath), safeText, { encoding: 'utf8', flag: 'wx' });
77
+ if (file.relativePath === 'package.json')
78
+ wroteManifest = true;
79
+ }
80
+ if (!wroteManifest && candidates.length > 0) {
81
+ const dependencies = Object.fromEntries(candidates.map((candidate) => {
82
+ const { name, version } = candidateParts(candidate);
83
+ return [name, version];
84
+ }));
85
+ await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name: 'dsh-doctor-isolated-profile', private: true, dependencies }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
60
86
  }
61
87
  }
62
88
  function runtimeForTarget(model, targetDsh) {
@@ -111,11 +137,19 @@ export async function runPreflight(options) {
111
137
  const candidates = options.candidates.filter(safeCandidate);
112
138
  evidenceItems.push(evidence(profileDir, 'target-dsh', `Rehearsal target is DSH ${options.targetDsh}.`));
113
139
  evidenceItems.push(evidence(profileDir, 'candidate-plugins', `${candidates.length} validated candidate plugin reference(s) supplied; values are not copied into the real profile.`));
140
+ for (const candidate of candidates) {
141
+ evidenceItems.push(evidence(profileDir, 'candidate-accepted', `${candidate} was accepted as an exact package@version reference.`));
142
+ evidenceItems.push(evidence(profileDir, 'candidate-metadata-inspected', `${candidate} was injected into the isolated package metadata and included in static manifest analysis.`));
143
+ evidenceItems.push(evidence(profileDir, 'candidate-package-not-installed', `${candidate} was not downloaded or installed.`));
144
+ evidenceItems.push(evidence(profileDir, 'candidate-runtime-unverified', `${candidate} was not loaded, so runtime compatibility is unverified.`, 'warning'));
145
+ }
146
+ if (candidates.length > 0)
147
+ extraWarning = true;
114
148
  evidenceItems.push(evidence(profileDir, 'network-policy', options.online ? 'Online mode was requested, but this adapter performs no network operation.' : 'Offline mode enforced; no network operation was attempted.'));
115
149
  if (options.online)
116
150
  extraWarning = true;
117
151
  tempDirectory = await mkdtemp(join(tmpdir(), 'dsh-doctor-'));
118
- await prepareIsolatedProfile(input, tempDirectory, evidenceItems);
152
+ await prepareIsolatedProfile(input, tempDirectory, candidates, evidenceItems);
119
153
  evidenceItems.push(evidence(tempDirectory, 'isolated-profile', 'Only redacted, allow-listed composition metadata was copied into the temporary profile.'));
120
154
  try {
121
155
  const isolatedInput = await readProfile({ profileDir: tempDirectory });
@@ -52,5 +52,13 @@ export async function readProfile({ profileDir }) {
52
52
  throw error;
53
53
  }
54
54
  }
55
- return { profileDir: resolvedRoot, files };
55
+ return {
56
+ profileDir: resolvedRoot,
57
+ files,
58
+ metadataCoverage: {
59
+ mode: 'allow-listed-root-metadata',
60
+ scannedFiles: files.map((file) => file.relativePath).sort(),
61
+ unscannedSurfaces: ['nested plugin manifests and bundle metadata', 'environment files and values', 'private keys, tokens, sessions, and workspace source files']
62
+ }
63
+ };
56
64
  }
@@ -106,5 +106,7 @@ export function analyseComposition(model) {
106
106
  }
107
107
  }
108
108
  diagnostics.sort((left, right) => left.id.localeCompare(right.id) || left.title.localeCompare(right.title) || left.evidence[0]?.source.localeCompare(right.evidence[0]?.source ?? '') || 0);
109
- return { schemaVersion: 1, generatedAt: new Date().toISOString(), profileDir: model.profileDir, diagnostics };
109
+ const evidenceMode = model.evidenceMode ?? 'static';
110
+ const unverifiedFindings = diagnostics.filter((item) => item.id === 'runtime-composition-unavailable' || item.evidence.some((entry) => entry.evidenceKind === 'static')).map((item) => item.id);
111
+ return { schemaVersion: 1, generatedAt: new Date().toISOString(), profileDir: model.profileDir, evidenceMode, ...(model.metadataCoverage === undefined ? {} : { metadataCoverage: model.metadataCoverage }), unverifiedFindings, diagnostics };
110
112
  }
@@ -1,13 +1,14 @@
1
1
  import { lstat, readFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import z from '@deepseek-ai/schemastery';
4
+ import { defaultReportDirectory, resolveReportDirectory } from '../reports/location.js';
4
5
  export const name = 'dsh-composition-doctor';
5
6
  /** Public Cordis service dependency supplied by dsh-host-webserver. */
6
7
  export const inject = ['webServer'];
7
- export const Config = z.object({ reportDir: z.string() });
8
+ export const Config = z.object({ reportDir: z.string().default(defaultReportDirectory) });
8
9
  export const latestReportPath = '/dsh-composition-doctor/reports/latest';
9
10
  function reportDirectory(config) {
10
- return resolve(config.reportDir ?? '.dsh-composition-doctor/reports');
11
+ return resolveReportDirectory(config.reportDir);
11
12
  }
12
13
  function send(response, status, body) {
13
14
  response.statusCode = status;
@@ -0,0 +1,9 @@
1
+ import { resolve } from 'node:path';
2
+ /**
3
+ * Relative to the DSH host process working directory. This directory is owned
4
+ * by the doctor plugin and is deliberately separate from a scanned profile.
5
+ */
6
+ export const defaultReportDirectory = '.dsh-composition-doctor/reports';
7
+ export function resolveReportDirectory(reportDir = defaultReportDirectory) {
8
+ return resolve(reportDir);
9
+ }
@@ -10,5 +10,6 @@ function formatDiagnostic(item) {
10
10
  export function renderMarkdown(report) {
11
11
  const summary = ['error', 'warning', 'info'].map((severity) => `${severity}: ${report.diagnostics.filter((item) => item.severity === severity).length}`).join(', ');
12
12
  const body = report.diagnostics.length === 0 ? 'No diagnostics were produced.' : report.diagnostics.map(formatDiagnostic).join('\n\n');
13
- return `# DSH Composition Doctor Report\n\nSchema: ${report.schemaVersion}\n\nProfile: \`${report.profileDir}\`\n\nGenerated: ${report.generatedAt}\n\nSummary: ${summary}\n\n${body}\n`;
13
+ const coverage = report.metadataCoverage === undefined ? '' : `\nMetadata coverage: ${report.metadataCoverage.mode}; unscanned: ${report.metadataCoverage.unscannedSurfaces.join('; ')}\n`;
14
+ return `# DSH Composition Doctor Report\n\nSchema: ${report.schemaVersion}\n\nProfile: \`${report.profileDir}\`\n\nGenerated: ${report.generatedAt}\n\nEvidence mode: ${report.evidenceMode}\n${coverage}\nSummary: ${summary}\n\n${body}\n`;
14
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-composition-doctor",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "description": "Read-only DSH and Cordis composition diagnostics, snapshots, diffs, and isolated preflight.",
6
6
  "license": "MIT",
@@ -8,12 +8,14 @@
8
8
  "exports": {
9
9
  ".": "./dist/plugin/index.js",
10
10
  "./client": "./dist/client.js",
11
- "./cli": "./dist/cli/main.js"
11
+ "./cli": "./dist/cli/main.js",
12
+ "./package.json": "./package.json"
12
13
  },
13
14
  "files": [
14
15
  "dist",
15
16
  "cordis.patch.yml",
16
17
  "README.md",
18
+ "README.zh.md",
17
19
  "LICENSE"
18
20
  ],
19
21
  "dsh": {
@@ -37,7 +39,7 @@
37
39
  },
38
40
  "packageManager": "pnpm@10.17.1",
39
41
  "scripts": {
40
- "test": "vitest run",
42
+ "test": "pnpm build && vitest run",
41
43
  "typecheck": "tsc --noEmit",
42
44
  "build": "tsc && node scripts/build-client.mjs",
43
45
  "dsh-doctor": "node dist/cli/main.js"