dsh-composition-doctor 0.1.2 → 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
@@ -18,6 +18,14 @@ Composition and upgrade preflight doctor for [DeepSeek Harness](https://github.c
18
18
 
19
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.
20
20
 
21
+ ## Reports and evidence boundaries
22
+
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.
24
+
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.
26
+
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.
28
+
21
29
  ## Install
22
30
 
23
31
  ```powershell
@@ -30,7 +38,8 @@ Restart the Web UI (`npx @deepseek-ai/dsh web`) after changing a profile. The CL
30
38
  ## Example
31
39
 
32
40
  ```powershell
33
- dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\profile
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
34
43
  dsh-doctor snapshot --profile C:\path\to\profile --output .\reports\before.json
35
44
  dsh-doctor diff --before .\reports\before.json --after .\reports\after.json --format both
36
45
  dsh-doctor preflight --profile C:\path\to\profile --target-dsh 0.1.0-rc.6
package/README.zh.md CHANGED
@@ -15,12 +15,20 @@
15
15
 
16
16
  Web Settings 只读显示最近报告、冲突图并导出 JSON/Markdown,不提供修复、安装或卸载操作。
17
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
+
18
26
  ## 安装与使用
19
27
 
20
28
  ```powershell
21
29
  npm install -g dsh-composition-doctor
22
30
  npx @deepseek-ai/dsh plugin --profile web add dsh-composition-doctor
23
- dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\profile
31
+ dsh-doctor scan --profile C:\path\to\profile --format both --output .\reports\profile --publish
24
32
  ```
25
33
 
26
34
  默认操作只读或写入操作系统临时目录,不修改真实 profile,不扩大权限,不默认联网,不读取 `.env`、密钥、token、环境变量值、会话正文或工作区文件内容。证据不足时只报告 warning。
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.2",
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,7 +8,8 @@
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",
@@ -38,7 +39,7 @@
38
39
  },
39
40
  "packageManager": "pnpm@10.17.1",
40
41
  "scripts": {
41
- "test": "vitest run",
42
+ "test": "pnpm build && vitest run",
42
43
  "typecheck": "tsc --noEmit",
43
44
  "build": "tsc && node scripts/build-client.mjs",
44
45
  "dsh-doctor": "node dist/cli/main.js"