dsh-composition-doctor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/cordis.patch.yml +3 -0
- package/dist/cli/main.js +133 -0
- package/dist/client/index.js +30 -0
- package/dist/client/report-view.js +92 -0
- package/dist/client.js +204 -0
- package/dist/core/composition-adapter.js +91 -0
- package/dist/core/diff.js +69 -0
- package/dist/core/preflight.js +179 -0
- package/dist/core/profile-reader.js +56 -0
- package/dist/core/redaction.js +25 -0
- package/dist/core/rules.js +110 -0
- package/dist/core/snapshot.js +59 -0
- package/dist/core/types.js +1 -0
- package/dist/plugin/index.js +75 -0
- package/dist/reports/json.js +3 -0
- package/dist/reports/markdown.js +14 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DSH Composition Doctor contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# dsh-composition-doctor
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness (DSH) 的本地组合与升级预检插件。它只读取用户明确指定的 profile 元数据,输出有证据的 Cordis/插件组合诊断,并在独立临时目录中演练升级;不会修改真实 profile、安装或卸载插件,也不会扩大权限。
|
|
4
|
+
|
|
5
|
+
## 中文
|
|
6
|
+
|
|
7
|
+
### 功能
|
|
8
|
+
|
|
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 页面:只读显示最近报告、冲突关系和本地导出按钮。
|
|
14
|
+
|
|
15
|
+
### 安装与卸载
|
|
16
|
+
|
|
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
|
+
```
|
|
23
|
+
|
|
24
|
+
命令行也可直接运行 `node dist/cli/main.js`(或 `pnpm dsh-doctor`)。
|
|
25
|
+
|
|
26
|
+
### 使用
|
|
27
|
+
|
|
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
|
+
```
|
|
34
|
+
|
|
35
|
+
`--online` 只记录用户明确选择在线适配器的意图;当前实现仍不调用网络。`--allow-build` 是显式闸门,当前版本没有第三方构建 runner,因此不会执行生命周期脚本。
|
|
36
|
+
|
|
37
|
+
### 输出示例
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
warning runtime-composition-unavailable
|
|
41
|
+
evidence: cordis.yml (static)
|
|
42
|
+
explanation: 未注入公开的 resolved-composition provider,静态 YAML 不能证明运行时加载结果。
|
|
43
|
+
remediation: 在隔离 fixture 中提供公开 provider 后重跑。
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
完整示例见 [`docs/examples/scan-report.md`](docs/examples/scan-report.md)。
|
|
47
|
+
|
|
48
|
+
### 权限与隐私边界
|
|
49
|
+
|
|
50
|
+
默认只读,只读取显式 profile 根目录的 `cordis.yml`、`cordis.patch.yml`、`package.json` 和 lockfile 元数据,并拒绝符号链接。不会读取 `.env`、密钥、token、环境变量值、会话正文或工作区文件内容,不联网,不安装/卸载/迁移,不切换权限模式。快照递归脱敏敏感字段并只保留结构摘要与 SHA-256。工具不提供“安全评分”,也不声称插件本身安全。
|
|
51
|
+
|
|
52
|
+
### 支持范围与限制
|
|
53
|
+
|
|
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。
|
|
55
|
+
|
|
56
|
+
## English
|
|
57
|
+
|
|
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.
|
|
59
|
+
|
|
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).
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
pnpm test
|
|
66
|
+
pnpm typecheck
|
|
67
|
+
pnpm build
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Fixtures under `test/fixtures` cover healthy, duplicate row id, hook order, UI conflict, peer mismatch, redaction, and preflight isolation cases.
|
package/cordis.patch.yml
ADDED
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { resolveComposition } from '../core/composition-adapter.js';
|
|
5
|
+
import { diffSnapshots, renderSnapshotDiffMarkdown } from '../core/diff.js';
|
|
6
|
+
import { readProfile } from '../core/profile-reader.js';
|
|
7
|
+
import { createSnapshot } from '../core/snapshot.js';
|
|
8
|
+
import { analyseComposition } from '../core/rules.js';
|
|
9
|
+
import { renderJson } from '../reports/json.js';
|
|
10
|
+
import { renderMarkdown } from '../reports/markdown.js';
|
|
11
|
+
import { runPreflight } from '../core/preflight.js';
|
|
12
|
+
const usage = 'Usage: dsh-doctor <scan | snapshot | diff | preflight>';
|
|
13
|
+
const commands = new Set(['scan', 'snapshot', 'diff', 'preflight']);
|
|
14
|
+
function option(argv, name) {
|
|
15
|
+
const index = argv.indexOf(name);
|
|
16
|
+
return index >= 0 && typeof argv[index + 1] === 'string' ? argv[index + 1] : undefined;
|
|
17
|
+
}
|
|
18
|
+
function options(argv, name) {
|
|
19
|
+
const values = [];
|
|
20
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
21
|
+
if (argv[index] === name && typeof argv[index + 1] === 'string')
|
|
22
|
+
values.push(argv[index + 1]);
|
|
23
|
+
}
|
|
24
|
+
return values;
|
|
25
|
+
}
|
|
26
|
+
function invalid(io, message) {
|
|
27
|
+
io.write(`Invalid command: ${message}`);
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
async function scan(argv, io) {
|
|
31
|
+
const profile = option(argv, '--profile');
|
|
32
|
+
const output = option(argv, '--output');
|
|
33
|
+
const format = option(argv, '--format') ?? 'json';
|
|
34
|
+
if (profile === undefined || output === undefined)
|
|
35
|
+
return invalid(io, 'scan requires --profile and --output');
|
|
36
|
+
if (format !== 'json' && format !== 'markdown' && format !== 'both')
|
|
37
|
+
return invalid(io, '--format must be json, markdown, or both');
|
|
38
|
+
const report = analyseComposition(await resolveComposition(await readProfile({ profileDir: resolve(profile) })));
|
|
39
|
+
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');
|
|
45
|
+
io.write(`Wrote reports to ${destination}`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
async function snapshot(argv, io) {
|
|
49
|
+
const profile = option(argv, '--profile');
|
|
50
|
+
const output = option(argv, '--output');
|
|
51
|
+
if (profile === undefined || output === undefined)
|
|
52
|
+
return invalid(io, 'snapshot requires --profile and --output');
|
|
53
|
+
const destination = resolve(output);
|
|
54
|
+
await writeFile(destination, `${JSON.stringify(await createSnapshot(await readProfile({ profileDir: resolve(profile) })), null, 2)}\n`, 'utf8');
|
|
55
|
+
io.write(`Wrote snapshot to ${destination}`);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
function isSnapshot(value) {
|
|
59
|
+
return value !== null && typeof value === 'object' && value.schemaVersion === 1;
|
|
60
|
+
}
|
|
61
|
+
async function diff(argv, io) {
|
|
62
|
+
const beforePath = option(argv, '--before');
|
|
63
|
+
const afterPath = option(argv, '--after');
|
|
64
|
+
const format = option(argv, '--format') ?? 'json';
|
|
65
|
+
if (beforePath === undefined || afterPath === undefined)
|
|
66
|
+
return invalid(io, 'diff requires --before and --after');
|
|
67
|
+
if (format !== 'json' && format !== 'markdown' && format !== 'both')
|
|
68
|
+
return invalid(io, '--format must be json, markdown, or both');
|
|
69
|
+
const before = JSON.parse(await readFile(resolve(beforePath), 'utf8'));
|
|
70
|
+
const after = JSON.parse(await readFile(resolve(afterPath), 'utf8'));
|
|
71
|
+
if (!isSnapshot(before) || !isSnapshot(after))
|
|
72
|
+
return invalid(io, 'diff inputs must be schema version 1 snapshots');
|
|
73
|
+
const result = diffSnapshots(before, after);
|
|
74
|
+
if (format === 'json' || format === 'both')
|
|
75
|
+
io.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
76
|
+
if (format === 'markdown' || format === 'both')
|
|
77
|
+
io.write(renderSnapshotDiffMarkdown(result));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
async function preflight(argv, io) {
|
|
81
|
+
const profile = option(argv, '--profile');
|
|
82
|
+
const targetDsh = option(argv, '--target-dsh');
|
|
83
|
+
const output = option(argv, '--output');
|
|
84
|
+
if (profile === undefined || targetDsh === undefined)
|
|
85
|
+
return invalid(io, 'preflight requires --profile and --target-dsh');
|
|
86
|
+
const result = await runPreflight({
|
|
87
|
+
profileDir: resolve(profile),
|
|
88
|
+
targetDsh,
|
|
89
|
+
candidates: options(argv, '--candidate'),
|
|
90
|
+
allowBuild: argv.includes('--allow-build'),
|
|
91
|
+
online: argv.includes('--online'),
|
|
92
|
+
keepTemp: argv.includes('--keep-temp')
|
|
93
|
+
});
|
|
94
|
+
const rendered = `${JSON.stringify(result, null, 2)}\n`;
|
|
95
|
+
if (output === undefined)
|
|
96
|
+
io.write(rendered);
|
|
97
|
+
else {
|
|
98
|
+
const destination = resolve(output);
|
|
99
|
+
await mkdir(resolve(destination, '..'), { recursive: true });
|
|
100
|
+
await writeFile(destination, rendered, 'utf8');
|
|
101
|
+
io.write(`Wrote preflight report to ${destination}`);
|
|
102
|
+
}
|
|
103
|
+
return result.outcome === 'fail' ? 1 : 0;
|
|
104
|
+
}
|
|
105
|
+
export async function runCli(argv, io = { write: (line) => console.log(line) }) {
|
|
106
|
+
const command = argv[0];
|
|
107
|
+
if (argv.length === 0 || command === '--help') {
|
|
108
|
+
io.write(usage);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
if (!commands.has(command)) {
|
|
112
|
+
io.write(`Unknown command: ${command}`);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
if (command === 'scan')
|
|
117
|
+
return await scan(argv.slice(1), io);
|
|
118
|
+
if (command === 'snapshot')
|
|
119
|
+
return await snapshot(argv.slice(1), io);
|
|
120
|
+
if (command === 'diff')
|
|
121
|
+
return await diff(argv.slice(1), io);
|
|
122
|
+
return await preflight(argv.slice(1), io);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
io.write(error instanceof Error ? error.message : String(error));
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
130
|
+
runCli(process.argv.slice(2)).then((exitCode) => {
|
|
131
|
+
process.exitCode = exitCode;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createReportView } from './report-view.js';
|
|
2
|
+
export const name = 'dsh-composition-doctor';
|
|
3
|
+
export const registration = Object.freeze({
|
|
4
|
+
id: name,
|
|
5
|
+
actions: ['export-json', 'export-markdown'],
|
|
6
|
+
component: () => createReportView()
|
|
7
|
+
});
|
|
8
|
+
export const apply = Object.assign((ctx) => {
|
|
9
|
+
const messages = {
|
|
10
|
+
zh: { title: 'DSH 组合医生', exportJson: '导出 JSON', exportMarkdown: '导出 Markdown' },
|
|
11
|
+
en: { title: 'DSH Composition Doctor', exportJson: 'Export JSON', exportMarkdown: 'Export Markdown' }
|
|
12
|
+
};
|
|
13
|
+
const disposeLocale = ctx.locale?.register('dshCompositionDoctor', messages);
|
|
14
|
+
const t = ctx.locale?.bind('dshCompositionDoctor') ?? ((key) => messages.en[key] ?? key);
|
|
15
|
+
const disposeSlot = ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
|
16
|
+
name: 'settings.plugins.tab', id: registration.id, order: 70,
|
|
17
|
+
locale: 'dshCompositionDoctor', label: () => t('title')
|
|
18
|
+
}, () => createReportView(undefined, t)));
|
|
19
|
+
return () => {
|
|
20
|
+
if (typeof disposeSlot === 'function')
|
|
21
|
+
disposeSlot();
|
|
22
|
+
if (typeof disposeLocale === 'function')
|
|
23
|
+
disposeLocale();
|
|
24
|
+
};
|
|
25
|
+
}, { inject: ['slots', 'locale'] });
|
|
26
|
+
/** Stable, test-friendly metadata for the Web Settings contract. */
|
|
27
|
+
export function clientRegistration() {
|
|
28
|
+
return registration;
|
|
29
|
+
}
|
|
30
|
+
export default apply;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { renderMarkdown } from '../reports/markdown.js';
|
|
2
|
+
export const clientReportPath = '/dsh-composition-doctor/reports/latest';
|
|
3
|
+
export function buildConflictGraph(report) {
|
|
4
|
+
const nodes = new Map();
|
|
5
|
+
const edges = [];
|
|
6
|
+
for (const diagnostic of report.diagnostics) {
|
|
7
|
+
const evidence = diagnostic.evidence;
|
|
8
|
+
const evidenceNodes = evidence.map((item, index) => {
|
|
9
|
+
const id = `${diagnostic.id}:${index}`;
|
|
10
|
+
nodes.set(id, { id, label: item.subject ?? item.packageName ?? item.detail, source: item.source });
|
|
11
|
+
return id;
|
|
12
|
+
});
|
|
13
|
+
for (let index = 1; index < evidenceNodes.length; index += 1) {
|
|
14
|
+
edges.push({ from: evidenceNodes[0], to: evidenceNodes[index], diagnosticId: diagnostic.id });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { nodes: [...nodes.values()], edges };
|
|
18
|
+
}
|
|
19
|
+
export function toReportViewModel(report) {
|
|
20
|
+
const counts = { info: 0, warning: 0, error: 0 };
|
|
21
|
+
for (const diagnostic of report.diagnostics)
|
|
22
|
+
counts[diagnostic.severity] += 1;
|
|
23
|
+
return { report, counts, graph: buildConflictGraph(report) };
|
|
24
|
+
}
|
|
25
|
+
function isReport(value) {
|
|
26
|
+
if (value === null || typeof value !== 'object')
|
|
27
|
+
return false;
|
|
28
|
+
const candidate = value;
|
|
29
|
+
return candidate.schemaVersion === 1 && Array.isArray(candidate.diagnostics);
|
|
30
|
+
}
|
|
31
|
+
function text(document, value) {
|
|
32
|
+
return document.createTextNode(value);
|
|
33
|
+
}
|
|
34
|
+
function button(document, label, onClick) {
|
|
35
|
+
const element = document.createElement('button');
|
|
36
|
+
element.type = 'button';
|
|
37
|
+
element.textContent = label;
|
|
38
|
+
element.addEventListener('click', onClick);
|
|
39
|
+
return element;
|
|
40
|
+
}
|
|
41
|
+
async function fetchReport(fetcher = globalThis.fetch) {
|
|
42
|
+
const response = await fetcher(clientReportPath, { method: 'GET', cache: 'no-store' });
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new Error(`Report request failed (${response.status})`);
|
|
45
|
+
const value = await response.json();
|
|
46
|
+
if (!isReport(value))
|
|
47
|
+
throw new Error('The latest report has an unsupported schema.');
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function download(document, content, filename, mime) {
|
|
51
|
+
const link = document.createElement('a');
|
|
52
|
+
link.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
|
53
|
+
link.download = filename;
|
|
54
|
+
link.click();
|
|
55
|
+
URL.revokeObjectURL(link.href);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Browser-native settings section. It only performs a GET and offers local
|
|
59
|
+
* downloads; it contains no profile, package, configuration, or repair writes.
|
|
60
|
+
*/
|
|
61
|
+
export function createReportView(document = globalThis.document, translate = (key) => ({
|
|
62
|
+
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)) {
|
|
64
|
+
const root = document.createElement('section');
|
|
65
|
+
root.dataset.plugin = nameForDom;
|
|
66
|
+
root.setAttribute('aria-labelledby', 'dsh-composition-doctor-title');
|
|
67
|
+
const heading = document.createElement('h2');
|
|
68
|
+
heading.id = 'dsh-composition-doctor-title';
|
|
69
|
+
heading.append(text(document, translate('title')));
|
|
70
|
+
root.append(heading);
|
|
71
|
+
const status = document.createElement('p');
|
|
72
|
+
status.textContent = translate('loading');
|
|
73
|
+
root.append(status);
|
|
74
|
+
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); }));
|
|
76
|
+
root.append(actions);
|
|
77
|
+
void fetchReport().then((report) => {
|
|
78
|
+
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.`;
|
|
80
|
+
const list = document.createElement('ul');
|
|
81
|
+
for (const diagnostic of report.diagnostics) {
|
|
82
|
+
const item = document.createElement('li');
|
|
83
|
+
item.textContent = `[${diagnostic.severity}] ${diagnostic.title}`;
|
|
84
|
+
list.append(item);
|
|
85
|
+
}
|
|
86
|
+
root.append(list);
|
|
87
|
+
}).catch((error) => {
|
|
88
|
+
status.textContent = error instanceof Error ? error.message : translate('unavailable');
|
|
89
|
+
});
|
|
90
|
+
return root;
|
|
91
|
+
}
|
|
92
|
+
const nameForDom = 'dsh-composition-doctor';
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-composition-doctor",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
"use strict";
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
10
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
11
|
+
var __export = (target, all) => {
|
|
12
|
+
for (var name2 in all)
|
|
13
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
14
|
+
};
|
|
15
|
+
var __copyProps = (to, from, except, desc) => {
|
|
16
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from))
|
|
18
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
24
|
+
|
|
25
|
+
// src/client/index.ts
|
|
26
|
+
var index_exports = {};
|
|
27
|
+
__export(index_exports, {
|
|
28
|
+
apply: () => apply,
|
|
29
|
+
clientRegistration: () => clientRegistration,
|
|
30
|
+
default: () => index_default,
|
|
31
|
+
name: () => name,
|
|
32
|
+
registration: () => registration
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
|
|
36
|
+
// src/reports/markdown.ts
|
|
37
|
+
function formatEvidence(item) {
|
|
38
|
+
const subject = item.subject === void 0 ? "" : ` \u2014 ${item.subject}`;
|
|
39
|
+
const packageLabel = item.packageName === void 0 ? "" : ` (${item.packageName}${item.version === void 0 ? "" : `@${item.version}`})`;
|
|
40
|
+
return `- \`${item.source}\`${subject}${packageLabel}: ${item.detail}`;
|
|
41
|
+
}
|
|
42
|
+
function formatDiagnostic(item) {
|
|
43
|
+
const evidence = item.evidence.length === 0 ? "- No concrete source evidence was supplied." : item.evidence.map(formatEvidence).join("\n");
|
|
44
|
+
return `## [${item.severity.toUpperCase()}] ${item.title}
|
|
45
|
+
|
|
46
|
+
${item.explanation}
|
|
47
|
+
|
|
48
|
+
Evidence:
|
|
49
|
+
${evidence}
|
|
50
|
+
|
|
51
|
+
Remediation: ${item.remediation}`;
|
|
52
|
+
}
|
|
53
|
+
function renderMarkdown(report) {
|
|
54
|
+
const summary = ["error", "warning", "info"].map((severity) => `${severity}: ${report.diagnostics.filter((item) => item.severity === severity).length}`).join(", ");
|
|
55
|
+
const body = report.diagnostics.length === 0 ? "No diagnostics were produced." : report.diagnostics.map(formatDiagnostic).join("\n\n");
|
|
56
|
+
return `# DSH Composition Doctor Report
|
|
57
|
+
|
|
58
|
+
Schema: ${report.schemaVersion}
|
|
59
|
+
|
|
60
|
+
Profile: \`${report.profileDir}\`
|
|
61
|
+
|
|
62
|
+
Generated: ${report.generatedAt}
|
|
63
|
+
|
|
64
|
+
Summary: ${summary}
|
|
65
|
+
|
|
66
|
+
${body}
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/client/report-view.ts
|
|
71
|
+
var clientReportPath = "/dsh-composition-doctor/reports/latest";
|
|
72
|
+
function buildConflictGraph(report) {
|
|
73
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
74
|
+
const edges = [];
|
|
75
|
+
for (const diagnostic of report.diagnostics) {
|
|
76
|
+
const evidence = diagnostic.evidence;
|
|
77
|
+
const evidenceNodes = evidence.map((item, index) => {
|
|
78
|
+
const id = `${diagnostic.id}:${index}`;
|
|
79
|
+
nodes.set(id, { id, label: item.subject ?? item.packageName ?? item.detail, source: item.source });
|
|
80
|
+
return id;
|
|
81
|
+
});
|
|
82
|
+
for (let index = 1; index < evidenceNodes.length; index += 1) {
|
|
83
|
+
edges.push({ from: evidenceNodes[0], to: evidenceNodes[index], diagnosticId: diagnostic.id });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { nodes: [...nodes.values()], edges };
|
|
87
|
+
}
|
|
88
|
+
function toReportViewModel(report) {
|
|
89
|
+
const counts = { info: 0, warning: 0, error: 0 };
|
|
90
|
+
for (const diagnostic of report.diagnostics) counts[diagnostic.severity] += 1;
|
|
91
|
+
return { report, counts, graph: buildConflictGraph(report) };
|
|
92
|
+
}
|
|
93
|
+
function isReport(value) {
|
|
94
|
+
if (value === null || typeof value !== "object") return false;
|
|
95
|
+
const candidate = value;
|
|
96
|
+
return candidate.schemaVersion === 1 && Array.isArray(candidate.diagnostics);
|
|
97
|
+
}
|
|
98
|
+
function text(document, value) {
|
|
99
|
+
return document.createTextNode(value);
|
|
100
|
+
}
|
|
101
|
+
function button(document, label, onClick) {
|
|
102
|
+
const element = document.createElement("button");
|
|
103
|
+
element.type = "button";
|
|
104
|
+
element.textContent = label;
|
|
105
|
+
element.addEventListener("click", onClick);
|
|
106
|
+
return element;
|
|
107
|
+
}
|
|
108
|
+
async function fetchReport(fetcher = globalThis.fetch) {
|
|
109
|
+
const response = await fetcher(clientReportPath, { method: "GET", cache: "no-store" });
|
|
110
|
+
if (!response.ok) throw new Error(`Report request failed (${response.status})`);
|
|
111
|
+
const value = await response.json();
|
|
112
|
+
if (!isReport(value)) throw new Error("The latest report has an unsupported schema.");
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function download(document, content, filename, mime) {
|
|
116
|
+
const link = document.createElement("a");
|
|
117
|
+
link.href = URL.createObjectURL(new Blob([content], { type: mime }));
|
|
118
|
+
link.download = filename;
|
|
119
|
+
link.click();
|
|
120
|
+
URL.revokeObjectURL(link.href);
|
|
121
|
+
}
|
|
122
|
+
function createReportView(document = globalThis.document, translate = (key) => ({
|
|
123
|
+
title: "DSH Composition Doctor",
|
|
124
|
+
exportJson: "Export JSON",
|
|
125
|
+
exportMarkdown: "Export Markdown",
|
|
126
|
+
loading: "Loading the latest local report\u2026",
|
|
127
|
+
unavailable: "The latest report is unavailable."
|
|
128
|
+
})[key] ?? key) {
|
|
129
|
+
const root = document.createElement("section");
|
|
130
|
+
root.dataset.plugin = nameForDom;
|
|
131
|
+
root.setAttribute("aria-labelledby", "dsh-composition-doctor-title");
|
|
132
|
+
const heading = document.createElement("h2");
|
|
133
|
+
heading.id = "dsh-composition-doctor-title";
|
|
134
|
+
heading.append(text(document, translate("title")));
|
|
135
|
+
root.append(heading);
|
|
136
|
+
const status = document.createElement("p");
|
|
137
|
+
status.textContent = translate("loading");
|
|
138
|
+
root.append(status);
|
|
139
|
+
const actions = document.createElement("p");
|
|
140
|
+
actions.append(
|
|
141
|
+
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);
|
|
144
|
+
}),
|
|
145
|
+
text(document, " "),
|
|
146
|
+
button(document, translate("exportMarkdown"), () => {
|
|
147
|
+
void fetchReport().then((report) => download(document, renderMarkdown(report), "dsh-composition-doctor-report.md", "text/markdown")).catch(() => void 0);
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
root.append(actions);
|
|
151
|
+
void fetchReport().then((report) => {
|
|
152
|
+
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.`;
|
|
154
|
+
const list = document.createElement("ul");
|
|
155
|
+
for (const diagnostic of report.diagnostics) {
|
|
156
|
+
const item = document.createElement("li");
|
|
157
|
+
item.textContent = `[${diagnostic.severity}] ${diagnostic.title}`;
|
|
158
|
+
list.append(item);
|
|
159
|
+
}
|
|
160
|
+
root.append(list);
|
|
161
|
+
}).catch((error) => {
|
|
162
|
+
status.textContent = error instanceof Error ? error.message : translate("unavailable");
|
|
163
|
+
});
|
|
164
|
+
return root;
|
|
165
|
+
}
|
|
166
|
+
var nameForDom = "dsh-composition-doctor";
|
|
167
|
+
|
|
168
|
+
// src/client/index.ts
|
|
169
|
+
var name = "dsh-composition-doctor";
|
|
170
|
+
var registration = Object.freeze({
|
|
171
|
+
id: name,
|
|
172
|
+
actions: ["export-json", "export-markdown"],
|
|
173
|
+
component: () => createReportView()
|
|
174
|
+
});
|
|
175
|
+
var apply = Object.assign(
|
|
176
|
+
(ctx) => {
|
|
177
|
+
const messages = {
|
|
178
|
+
zh: { title: "DSH \u7EC4\u5408\u533B\u751F", exportJson: "\u5BFC\u51FA JSON", exportMarkdown: "\u5BFC\u51FA Markdown" },
|
|
179
|
+
en: { title: "DSH Composition Doctor", exportJson: "Export JSON", exportMarkdown: "Export Markdown" }
|
|
180
|
+
};
|
|
181
|
+
const disposeLocale = ctx.locale?.register("dshCompositionDoctor", messages);
|
|
182
|
+
const t = ctx.locale?.bind("dshCompositionDoctor") ?? ((key) => messages.en[key] ?? key);
|
|
183
|
+
const disposeSlot = ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
|
|
184
|
+
name: "settings.plugins.tab",
|
|
185
|
+
id: registration.id,
|
|
186
|
+
order: 70,
|
|
187
|
+
locale: "dshCompositionDoctor",
|
|
188
|
+
label: () => t("title")
|
|
189
|
+
}, () => createReportView(void 0, t)));
|
|
190
|
+
return () => {
|
|
191
|
+
if (typeof disposeSlot === "function") disposeSlot();
|
|
192
|
+
if (typeof disposeLocale === "function") disposeLocale();
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
{ inject: ["slots", "locale"] }
|
|
196
|
+
);
|
|
197
|
+
function clientRegistration() {
|
|
198
|
+
return registration;
|
|
199
|
+
}
|
|
200
|
+
var index_default = apply;
|
|
201
|
+
|
|
202
|
+
return module.exports;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
function runtimeUnavailableDiagnostic() {
|
|
3
|
+
return {
|
|
4
|
+
id: 'runtime-composition-unavailable',
|
|
5
|
+
severity: 'warning',
|
|
6
|
+
title: 'Runtime composition is unavailable',
|
|
7
|
+
evidence: [],
|
|
8
|
+
explanation: 'Static YAML was read, but actual resolved composition requires a public provider.',
|
|
9
|
+
remediation: 'Supply a public ResolvedCompositionProvider to resolve runtime composition.'
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function parseFailedDiagnostic(file, error) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'composition-parse-failed',
|
|
15
|
+
severity: 'warning',
|
|
16
|
+
title: 'Composition YAML could not be parsed',
|
|
17
|
+
evidence: [{ source: file.relativePath, detail: error instanceof Error ? error.message : String(error) }],
|
|
18
|
+
explanation: 'This static composition source was skipped because its YAML is invalid.',
|
|
19
|
+
remediation: 'Correct the YAML syntax and run the static analysis again.'
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function isPlainObject(value) {
|
|
26
|
+
const prototype = Object.getPrototypeOf(value);
|
|
27
|
+
return prototype === Object.prototype || prototype === null;
|
|
28
|
+
}
|
|
29
|
+
function cloneProviderValue(value, seen = new WeakMap()) {
|
|
30
|
+
if (value === null || typeof value !== 'object')
|
|
31
|
+
return value;
|
|
32
|
+
const existing = seen.get(value);
|
|
33
|
+
if (existing !== undefined)
|
|
34
|
+
return existing;
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
const copy = [];
|
|
37
|
+
seen.set(value, copy);
|
|
38
|
+
for (const item of value)
|
|
39
|
+
copy.push(cloneProviderValue(item, seen));
|
|
40
|
+
return copy;
|
|
41
|
+
}
|
|
42
|
+
if (!isPlainObject(value))
|
|
43
|
+
return value;
|
|
44
|
+
const copy = Object.create(null);
|
|
45
|
+
seen.set(value, copy);
|
|
46
|
+
for (const property of Object.keys(value)) {
|
|
47
|
+
copy[property] = cloneProviderValue(value[property], seen);
|
|
48
|
+
}
|
|
49
|
+
return copy;
|
|
50
|
+
}
|
|
51
|
+
function staticRows(file) {
|
|
52
|
+
const parsed = parse(file.text);
|
|
53
|
+
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) ? parsed.plugins : undefined;
|
|
54
|
+
if (!Array.isArray(entries))
|
|
55
|
+
return [];
|
|
56
|
+
return entries.flatMap((entry) => {
|
|
57
|
+
if (!isRecord(entry))
|
|
58
|
+
return [];
|
|
59
|
+
const row = { source: file.relativePath, evidenceKind: 'static' };
|
|
60
|
+
if (typeof entry.id === 'string')
|
|
61
|
+
row.id = entry.id;
|
|
62
|
+
if (typeof entry.name === 'string')
|
|
63
|
+
row.name = entry.name;
|
|
64
|
+
if ('config' in entry)
|
|
65
|
+
row.config = entry.config;
|
|
66
|
+
return [row];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export async function resolveComposition(input, provider) {
|
|
70
|
+
if (provider) {
|
|
71
|
+
const providedRows = await provider.resolve(input);
|
|
72
|
+
return {
|
|
73
|
+
profileDir: input.profileDir,
|
|
74
|
+
rows: providedRows.map((row) => ({ ...cloneProviderValue(row), evidenceKind: 'resolved' })),
|
|
75
|
+
adapterDiagnostics: []
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const rows = [];
|
|
79
|
+
const adapterDiagnostics = [runtimeUnavailableDiagnostic()];
|
|
80
|
+
for (const file of input.files) {
|
|
81
|
+
if (file.relativePath !== 'cordis.yml' && file.relativePath !== 'cordis.patch.yml')
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
rows.push(...staticRows(file));
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
adapterDiagnostics.push(parseFailedDiagnostic(file, error));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { profileDir: input.profileDir, rows, adapterDiagnostics };
|
|
91
|
+
}
|