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 +10 -1
- package/README.zh.md +9 -1
- package/dist/cli/main.js +16 -7
- package/dist/client/index.js +4 -3
- package/dist/client/report-view.js +68 -10
- package/dist/client.js +72 -14
- package/dist/core/composition-adapter.js +33 -2
- package/dist/core/diff.js +20 -14
- package/dist/core/preflight.js +36 -2
- package/dist/core/profile-reader.js +9 -1
- package/dist/core/rules.js +3 -1
- package/dist/plugin/index.js +3 -2
- package/dist/reports/location.js +9 -0
- package/dist/reports/markdown.js +2 -1
- package/package.json +4 -3
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
|
-
|
|
41
|
-
|
|
42
|
-
await
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
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
|
}
|
package/dist/client/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
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: () =>
|
|
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
|
-
}, () =>
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
const objectUrl = hooks.createObjectURL(blob);
|
|
62
|
+
link.hidden = true;
|
|
63
|
+
link.href = objectUrl;
|
|
53
64
|
link.download = filename;
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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((
|
|
88
|
-
status.textContent =
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
const objectUrl = hooks.createObjectURL(blob);
|
|
134
|
+
link.hidden = true;
|
|
135
|
+
link.href = objectUrl;
|
|
118
136
|
link.download = filename;
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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((
|
|
162
|
-
status.textContent =
|
|
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: () =>
|
|
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
|
-
}, () =>
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
32
36
|
}
|
|
33
|
-
function changes(before, after,
|
|
34
|
-
const previous = new Map(before.map((item) => [
|
|
35
|
-
const next = new Map(after.map((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
|
|
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
|
|
58
|
+
return { ...change, name };
|
|
53
59
|
const comparison = compareVersions(change.before?.version, change.after?.version);
|
|
54
|
-
return { ...change, name
|
|
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 ?? ''}`);
|
package/dist/core/preflight.js
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/dist/core/rules.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/plugin/index.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/reports/markdown.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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"
|