dsh-composition-doctor 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +23 -7
- package/README.zh.md +56 -11
- package/dist/cli/main.js +66 -18
- package/dist/client/index.js +5 -4
- package/dist/client/report-view.js +110 -11
- package/dist/client.js +112 -18
- package/dist/core/bundle-inventory.js +66 -0
- package/dist/core/composition-adapter.js +56 -9
- package/dist/core/composition-provenance.js +8 -0
- package/dist/core/diff.js +41 -36
- package/dist/core/dsh-dump-parser.js +111 -0
- package/dist/core/dsh-dump-provider.js +93 -0
- package/dist/core/package-provenance.js +80 -0
- package/dist/core/preflight/artifact-resolver.js +150 -0
- package/dist/core/preflight/candidate-artifact-resolver.js +61 -0
- package/dist/core/preflight/npm-artifact.js +132 -0
- package/dist/core/preflight/package-manager.js +4 -0
- package/dist/core/preflight/package-resolution.js +66 -0
- package/dist/core/preflight/rehearsal-profile.js +6 -0
- package/dist/core/preflight/smoke.js +1 -0
- package/dist/core/preflight.js +98 -11
- package/dist/core/profile-reader.js +16 -1
- package/dist/core/redaction.js +28 -1
- package/dist/core/rules.js +64 -12
- package/dist/core/snapshot.js +60 -46
- package/dist/plugin/index.js +16 -3
- package/dist/reports/location.js +9 -0
- package/dist/reports/markdown.js +3 -2
- package/dist/reports/schema.js +49 -0
- package/package.json +18 -10
package/dist/client.js
CHANGED
|
@@ -32,12 +32,16 @@ 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) {
|
|
38
42
|
const subject = item.subject === void 0 ? "" : ` \u2014 ${item.subject}`;
|
|
39
43
|
const packageLabel = item.packageName === void 0 ? "" : ` (${item.packageName}${item.version === void 0 ? "" : `@${item.version}`})`;
|
|
40
|
-
return `- \`${item.source}\`${subject}${packageLabel}: ${item.detail}`;
|
|
44
|
+
return `- [${item.evidenceKind}] \`${item.source}\`${subject}${packageLabel}: ${item.detail}`;
|
|
41
45
|
}
|
|
42
46
|
function formatDiagnostic(item) {
|
|
43
47
|
const evidence = item.evidence.length === 0 ? "- No concrete source evidence was supplied." : item.evidence.map(formatEvidence).join("\n");
|
|
@@ -53,14 +57,19 @@ 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
|
-
Schema: ${report.schemaVersion}
|
|
65
|
+
Schema: ${report.schemaVersion}; evidence schema: ${report.evidenceSchemaVersion}
|
|
59
66
|
|
|
60
67
|
Profile: \`${report.profileDir}\`
|
|
61
68
|
|
|
62
69
|
Generated: ${report.generatedAt}
|
|
63
70
|
|
|
71
|
+
Evidence mode: ${report.evidenceMode}; runtime observed: ${report.runtimeObserved ? "yes" : "no"}
|
|
72
|
+
${coverage}
|
|
64
73
|
Summary: ${summary}
|
|
65
74
|
|
|
66
75
|
${body}
|
|
@@ -69,6 +78,15 @@ 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;
|
|
82
|
+
function evidenceBadgeLabel(mode, runtimeObserved) {
|
|
83
|
+
if (mode === "runtime-observed" && runtimeObserved) return "runtime-observed";
|
|
84
|
+
if (mode === "runtime-observed") return "composed; runtime not observed";
|
|
85
|
+
if (mode === "composed") return "composed; runtime not observed";
|
|
86
|
+
if (mode === "mixed") return runtimeObserved ? "mixed; runtime observed" : "mixed; runtime not observed";
|
|
87
|
+
if (mode === "resolved") return "legacy resolved; runtime not observed";
|
|
88
|
+
return "static";
|
|
89
|
+
}
|
|
72
90
|
function buildConflictGraph(report) {
|
|
73
91
|
const nodes = /* @__PURE__ */ new Map();
|
|
74
92
|
const edges = [];
|
|
@@ -90,6 +108,28 @@ window.__ModuleLoader__.load({
|
|
|
90
108
|
for (const diagnostic of report.diagnostics) counts[diagnostic.severity] += 1;
|
|
91
109
|
return { report, counts, graph: buildConflictGraph(report) };
|
|
92
110
|
}
|
|
111
|
+
function renderConflictGraph(document, graph) {
|
|
112
|
+
const section = document.createElement("section");
|
|
113
|
+
section.setAttribute("aria-labelledby", "dsh-composition-doctor-conflict-graph");
|
|
114
|
+
const heading = document.createElement("h3");
|
|
115
|
+
heading.id = "dsh-composition-doctor-conflict-graph";
|
|
116
|
+
heading.textContent = "Conflict graph";
|
|
117
|
+
section.append(heading);
|
|
118
|
+
const list = document.createElement("ul");
|
|
119
|
+
for (const node of graph.nodes) {
|
|
120
|
+
const item = document.createElement("li");
|
|
121
|
+
const outgoing = graph.edges.filter((edge) => edge.from === node.id).map((edge) => graph.nodes.find((candidate) => candidate.id === edge.to)?.label ?? edge.to);
|
|
122
|
+
item.textContent = `${node.label} (${node.source})${outgoing.length === 0 ? "" : ` \u2192 ${outgoing.join(", ")}`}`;
|
|
123
|
+
list.append(item);
|
|
124
|
+
}
|
|
125
|
+
if (graph.nodes.length === 0) {
|
|
126
|
+
const item = document.createElement("li");
|
|
127
|
+
item.textContent = "No evidence relationships were produced.";
|
|
128
|
+
list.append(item);
|
|
129
|
+
}
|
|
130
|
+
section.append(list);
|
|
131
|
+
return section;
|
|
132
|
+
}
|
|
93
133
|
function isReport(value) {
|
|
94
134
|
if (value === null || typeof value !== "object") return false;
|
|
95
135
|
const candidate = value;
|
|
@@ -112,12 +152,36 @@ window.__ModuleLoader__.load({
|
|
|
112
152
|
if (!isReport(value)) throw new Error("The latest report has an unsupported schema.");
|
|
113
153
|
return value;
|
|
114
154
|
}
|
|
115
|
-
|
|
155
|
+
var browserDownloadHooks = {
|
|
156
|
+
createObjectURL: (blob) => URL.createObjectURL(blob),
|
|
157
|
+
revokeObjectURL: (url) => URL.revokeObjectURL(url),
|
|
158
|
+
schedule: (callback, delay) => globalThis.setTimeout(callback, delay)
|
|
159
|
+
};
|
|
160
|
+
function download(document, content, filename, mime, hooks = browserDownloadHooks) {
|
|
161
|
+
const blob = new Blob([content], { type: mime });
|
|
116
162
|
const link = document.createElement("a");
|
|
117
|
-
|
|
163
|
+
const objectUrl = hooks.createObjectURL(blob);
|
|
164
|
+
link.hidden = true;
|
|
165
|
+
link.href = objectUrl;
|
|
118
166
|
link.download = filename;
|
|
119
|
-
|
|
120
|
-
|
|
167
|
+
document.body.append(link);
|
|
168
|
+
try {
|
|
169
|
+
link.click();
|
|
170
|
+
} finally {
|
|
171
|
+
link.remove();
|
|
172
|
+
hooks.schedule(() => hooks.revokeObjectURL(objectUrl), downloadRevokeDelayMs);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function exportReport(document, format, dependencies = {}) {
|
|
176
|
+
const report = await fetchReport(dependencies.fetcher);
|
|
177
|
+
if (format === "json") {
|
|
178
|
+
;
|
|
179
|
+
(dependencies.download ?? download)(document, `${JSON.stringify(report, null, 2)}
|
|
180
|
+
`, "dsh-composition-doctor-report.json", "application/json");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
;
|
|
184
|
+
(dependencies.download ?? download)(document, renderMarkdown(report), "dsh-composition-doctor-report.md", "text/markdown");
|
|
121
185
|
}
|
|
122
186
|
function createReportView(document = globalThis.document, translate = (key) => ({
|
|
123
187
|
title: "DSH Composition Doctor",
|
|
@@ -125,7 +189,7 @@ window.__ModuleLoader__.load({
|
|
|
125
189
|
exportMarkdown: "Export Markdown",
|
|
126
190
|
loading: "Loading the latest local report\u2026",
|
|
127
191
|
unavailable: "The latest report is unavailable."
|
|
128
|
-
})[key] ?? key) {
|
|
192
|
+
})[key] ?? key, dependencies = {}) {
|
|
129
193
|
const root = document.createElement("section");
|
|
130
194
|
root.dataset.plugin = nameForDom;
|
|
131
195
|
root.setAttribute("aria-labelledby", "dsh-composition-doctor-title");
|
|
@@ -139,30 +203,60 @@ window.__ModuleLoader__.load({
|
|
|
139
203
|
const actions = document.createElement("p");
|
|
140
204
|
actions.append(
|
|
141
205
|
button(document, translate("exportJson"), () => {
|
|
142
|
-
void
|
|
143
|
-
`, "dsh-composition-doctor-report.json", "application/json")).catch(() => void 0);
|
|
206
|
+
void exportReport(document, "json", dependencies).catch(() => void 0);
|
|
144
207
|
}),
|
|
145
208
|
text(document, " "),
|
|
146
209
|
button(document, translate("exportMarkdown"), () => {
|
|
147
|
-
void
|
|
210
|
+
void exportReport(document, "markdown", dependencies).catch(() => void 0);
|
|
148
211
|
})
|
|
149
212
|
);
|
|
150
213
|
root.append(actions);
|
|
151
|
-
void fetchReport().then((report) => {
|
|
214
|
+
void fetchReport(dependencies.fetcher).then((report) => {
|
|
152
215
|
const model = toReportViewModel(report);
|
|
153
|
-
|
|
216
|
+
const badge = document.createElement("span");
|
|
217
|
+
badge.dataset.evidenceKind = report.evidenceMode;
|
|
218
|
+
badge.setAttribute("aria-label", "Evidence capability");
|
|
219
|
+
badge.textContent = `Evidence: ${evidenceBadgeLabel(report.evidenceMode, report.runtimeObserved === true)}`;
|
|
220
|
+
status.textContent = `Generated: ${report.generatedAt}. Profile: ${report.profileDir}. Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info.`;
|
|
221
|
+
status.append(document.createTextNode(" "), badge);
|
|
222
|
+
root.append(renderConflictGraph(document, model.graph));
|
|
154
223
|
const list = document.createElement("ul");
|
|
155
224
|
for (const diagnostic of report.diagnostics) {
|
|
156
225
|
const item = document.createElement("li");
|
|
157
|
-
|
|
226
|
+
const details = document.createElement("details");
|
|
227
|
+
const summary = document.createElement("summary");
|
|
228
|
+
summary.textContent = `[${diagnostic.severity}] ${diagnostic.title}`;
|
|
229
|
+
details.append(summary);
|
|
230
|
+
const explanation = document.createElement("p");
|
|
231
|
+
explanation.textContent = diagnostic.explanation;
|
|
232
|
+
const evidence = document.createElement("pre");
|
|
233
|
+
evidence.textContent = diagnostic.evidence.map((entry) => `${entry.source}: ${entry.detail}`).join("\n") || "No concrete evidence.";
|
|
234
|
+
const remediation = document.createElement("p");
|
|
235
|
+
remediation.textContent = `Remediation: ${diagnostic.remediation}`;
|
|
236
|
+
details.append(explanation, evidence, remediation);
|
|
237
|
+
item.append(details);
|
|
158
238
|
list.append(item);
|
|
159
239
|
}
|
|
160
240
|
root.append(list);
|
|
161
|
-
}).catch((
|
|
162
|
-
status.textContent =
|
|
241
|
+
}).catch(() => {
|
|
242
|
+
status.textContent = translate("unavailable");
|
|
163
243
|
});
|
|
164
244
|
return root;
|
|
165
245
|
}
|
|
246
|
+
function ReportView({ translate }) {
|
|
247
|
+
const host = (0, import_react.useRef)(null);
|
|
248
|
+
(0, import_react.useEffect)(() => {
|
|
249
|
+
const container = host.current;
|
|
250
|
+
if (container === null) return;
|
|
251
|
+
const view = createReportView(globalThis.document, translate);
|
|
252
|
+
container.replaceChildren(view);
|
|
253
|
+
return () => {
|
|
254
|
+
view.remove();
|
|
255
|
+
container.replaceChildren();
|
|
256
|
+
};
|
|
257
|
+
}, [translate]);
|
|
258
|
+
return (0, import_react.createElement)("div", { className: "dsh-composition-doctor-report-view", ref: host });
|
|
259
|
+
}
|
|
166
260
|
var nameForDom = "dsh-composition-doctor";
|
|
167
261
|
|
|
168
262
|
// src/client/index.ts
|
|
@@ -170,12 +264,12 @@ window.__ModuleLoader__.load({
|
|
|
170
264
|
var registration = Object.freeze({
|
|
171
265
|
id: name,
|
|
172
266
|
actions: ["export-json", "export-markdown"],
|
|
173
|
-
component: () =>
|
|
267
|
+
component: () => (0, import_react2.createElement)(ReportView, { translate: (key) => key })
|
|
174
268
|
});
|
|
175
269
|
var apply = Object.assign(
|
|
176
270
|
(ctx) => {
|
|
177
271
|
const messages = {
|
|
178
|
-
zh: { title: "DSH
|
|
272
|
+
zh: { title: "DSH Composition Doctor", exportJson: "\u5BFC\u51FA JSON", exportMarkdown: "\u5BFC\u51FA Markdown" },
|
|
179
273
|
en: { title: "DSH Composition Doctor", exportJson: "Export JSON", exportMarkdown: "Export Markdown" }
|
|
180
274
|
};
|
|
181
275
|
const disposeLocale = ctx.locale?.register("dshCompositionDoctor", messages);
|
|
@@ -186,7 +280,7 @@ window.__ModuleLoader__.load({
|
|
|
186
280
|
order: 70,
|
|
187
281
|
locale: "dshCompositionDoctor",
|
|
188
282
|
label: () => t("title")
|
|
189
|
-
}, () =>
|
|
283
|
+
}, () => (0, import_react2.createElement)(ReportView, { translate: t })));
|
|
190
284
|
return () => {
|
|
191
285
|
if (typeof disposeSlot === "function") disposeSlot();
|
|
192
286
|
if (typeof disposeLocale === "function") disposeLocale();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { inspectInstalledPackage } from './package-provenance.js';
|
|
3
|
+
function record(value) {
|
|
4
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
5
|
+
}
|
|
6
|
+
function diagnostic(id, title, source, detail) {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
severity: 'warning',
|
|
10
|
+
title,
|
|
11
|
+
evidence: [{ source, detail, evidenceKind: 'static' }],
|
|
12
|
+
explanation: detail,
|
|
13
|
+
remediation: 'Install the declared bundle in the selected profile or provide the missing public manifest/patch metadata.'
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function bundleNames(manifest) {
|
|
17
|
+
const dsh = record(manifest.dsh);
|
|
18
|
+
const profile = record(dsh?.profile);
|
|
19
|
+
const bundles = profile?.bundles;
|
|
20
|
+
if (!Array.isArray(bundles))
|
|
21
|
+
return [];
|
|
22
|
+
return bundles.flatMap((entry) => {
|
|
23
|
+
if (typeof entry === 'string')
|
|
24
|
+
return [{ name: entry }];
|
|
25
|
+
const object = record(entry);
|
|
26
|
+
const name = typeof object?.name === 'string' ? object.name : typeof object?.package === 'string' ? object.package : undefined;
|
|
27
|
+
if (name === undefined)
|
|
28
|
+
return [];
|
|
29
|
+
return [{ name, ...(typeof object?.version === 'string' ? { requestedSpec: object.version } : {}) }];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export async function inspectBundleInventory(profileDir, packageJsonText) {
|
|
33
|
+
let manifest;
|
|
34
|
+
try {
|
|
35
|
+
manifest = JSON.parse(packageJsonText);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { packages: [], diagnostics: [diagnostic('bundle-inventory-manifest-invalid', 'Profile package manifest is invalid', 'package.json', 'The profile package.json could not be parsed, so dsh.profile.bundles cannot be inspected.')] };
|
|
39
|
+
}
|
|
40
|
+
const packages = [];
|
|
41
|
+
const diagnostics = [];
|
|
42
|
+
for (const declared of bundleNames(manifest)) {
|
|
43
|
+
try {
|
|
44
|
+
const fact = await inspectInstalledPackage(profileDir, declared.name, declared.requestedSpec);
|
|
45
|
+
packages.push(fact);
|
|
46
|
+
if (fact.bundlePatch !== undefined) {
|
|
47
|
+
try {
|
|
48
|
+
await access(fact.bundlePatch);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
diagnostics.push(diagnostic('bundle-patch-missing', 'Bundle patch metadata is missing', fact.packageJsonSource, `${declared.name} declares dsh.bundle.patch at ${fact.bundlePatch}, but that file is not readable.`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
diagnostics.push(diagnostic('bundle-patch-unavailable', 'Bundle patch metadata is unavailable', fact.packageJsonSource, `${declared.name} has no readable dsh.bundle.patch declaration.`));
|
|
56
|
+
}
|
|
57
|
+
if (fact.integrity === undefined && fact.gitRef === undefined) {
|
|
58
|
+
diagnostics.push(diagnostic('bundle-provenance-unavailable', 'Bundle immutable provenance is unavailable', fact.packageJsonSource, `${declared.name} has neither registry integrity nor a fixed Git reference in its readable manifest.`));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
diagnostics.push(diagnostic('bundle-not-installed', 'Declared bundle is not installed or readable', 'package.json', `${declared.name}${declared.requestedSpec === undefined ? '' : `@${declared.requestedSpec}`} could not be resolved from the selected profile: ${error instanceof Error ? error.message : String(error)}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { packages, diagnostics };
|
|
66
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { parse } from 'yaml';
|
|
2
|
-
|
|
2
|
+
import { tryDshDump } from './dsh-dump-provider.js';
|
|
3
|
+
function runtimeUnavailableDiagnostic(detail = 'Static YAML was read, but actual resolved composition requires a public provider.') {
|
|
3
4
|
return {
|
|
4
5
|
id: 'runtime-composition-unavailable',
|
|
5
6
|
severity: 'warning',
|
|
6
7
|
title: 'Runtime composition is unavailable',
|
|
7
8
|
evidence: [],
|
|
8
|
-
explanation:
|
|
9
|
-
remediation: '
|
|
9
|
+
explanation: detail,
|
|
10
|
+
remediation: 'Install or explicitly provide a compatible public DSH dump provider; static findings remain bounded to the allow-listed metadata.'
|
|
10
11
|
};
|
|
11
12
|
}
|
|
12
13
|
function parseFailedDiagnostic(file, error) {
|
|
@@ -14,7 +15,7 @@ function parseFailedDiagnostic(file, error) {
|
|
|
14
15
|
id: 'composition-parse-failed',
|
|
15
16
|
severity: 'warning',
|
|
16
17
|
title: 'Composition YAML could not be parsed',
|
|
17
|
-
evidence: [{ source: file.relativePath, detail: error instanceof Error ? error.message : String(error) }],
|
|
18
|
+
evidence: [{ source: file.relativePath, detail: error instanceof Error ? error.message : String(error), evidenceKind: 'static' }],
|
|
18
19
|
explanation: 'This static composition source was skipped because its YAML is invalid.',
|
|
19
20
|
remediation: 'Correct the YAML syntax and run the static analysis again.'
|
|
20
21
|
};
|
|
@@ -26,6 +27,14 @@ function isPlainObject(value) {
|
|
|
26
27
|
const prototype = Object.getPrototypeOf(value);
|
|
27
28
|
return prototype === Object.prototype || prototype === null;
|
|
28
29
|
}
|
|
30
|
+
function parseYamlValue(text) {
|
|
31
|
+
return parse(text, {
|
|
32
|
+
customTags: [
|
|
33
|
+
{ tag: 'tag:yaml.org,2002:js', resolve: (value) => value },
|
|
34
|
+
{ tag: 'tag:yaml.org,2002:js/function', resolve: (value) => value }
|
|
35
|
+
]
|
|
36
|
+
});
|
|
37
|
+
}
|
|
29
38
|
function cloneProviderValue(value, seen = new WeakMap()) {
|
|
30
39
|
if (value === null || typeof value !== 'object')
|
|
31
40
|
return value;
|
|
@@ -49,7 +58,7 @@ function cloneProviderValue(value, seen = new WeakMap()) {
|
|
|
49
58
|
return copy;
|
|
50
59
|
}
|
|
51
60
|
function staticRows(file) {
|
|
52
|
-
const parsed =
|
|
61
|
+
const parsed = parseYamlValue(file.text);
|
|
53
62
|
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) ? parsed.plugins : undefined;
|
|
54
63
|
if (!Array.isArray(entries))
|
|
55
64
|
return [];
|
|
@@ -66,17 +75,55 @@ function staticRows(file) {
|
|
|
66
75
|
return [row];
|
|
67
76
|
});
|
|
68
77
|
}
|
|
78
|
+
function inventoryFacts(input) {
|
|
79
|
+
const bundles = [];
|
|
80
|
+
const peerRequirements = [];
|
|
81
|
+
const platforms = [];
|
|
82
|
+
for (const fact of input.installedPackages) {
|
|
83
|
+
bundles.push({
|
|
84
|
+
name: fact.name,
|
|
85
|
+
source: fact.packageJsonSource,
|
|
86
|
+
evidenceKind: 'static',
|
|
87
|
+
...(fact.requestedSpec === undefined ? {} : { requestedSpec: fact.requestedSpec }),
|
|
88
|
+
...(fact.installedVersion === undefined ? {} : { version: fact.installedVersion }),
|
|
89
|
+
...(fact.gitRef === undefined ? {} : { gitRef: fact.gitRef }),
|
|
90
|
+
...(fact.integrity === undefined ? {} : { integrity: fact.integrity }),
|
|
91
|
+
...(fact.repository === undefined ? {} : { repository: fact.repository }),
|
|
92
|
+
...(fact.modifiedAt === undefined ? {} : { modifiedAt: fact.modifiedAt })
|
|
93
|
+
});
|
|
94
|
+
if (fact.peerDsh !== undefined || fact.peerCordis !== undefined || fact.engineNode !== undefined) {
|
|
95
|
+
peerRequirements.push({ packageName: fact.name, source: fact.packageJsonSource, evidenceKind: 'static', ...(fact.peerDsh === undefined ? {} : { dsh: fact.peerDsh }), ...(fact.peerCordis === undefined ? {} : { cordis: fact.peerCordis }), ...(fact.engineNode === undefined ? {} : { node: fact.engineNode }) });
|
|
96
|
+
}
|
|
97
|
+
if (fact.platform !== undefined && fact.platform.length > 0)
|
|
98
|
+
platforms.push({ packageName: fact.name, source: fact.packageJsonSource, evidenceKind: 'static', supported: fact.platform });
|
|
99
|
+
}
|
|
100
|
+
return { bundles, peerRequirements, platforms };
|
|
101
|
+
}
|
|
69
102
|
export async function resolveComposition(input, provider) {
|
|
70
103
|
if (provider) {
|
|
71
104
|
const providedRows = await provider.resolve(input);
|
|
72
105
|
return {
|
|
73
106
|
profileDir: input.profileDir,
|
|
74
|
-
rows: providedRows.map((row) => ({ ...cloneProviderValue(row), evidenceKind: '
|
|
75
|
-
adapterDiagnostics: []
|
|
107
|
+
rows: providedRows.map((row) => ({ ...cloneProviderValue(row), evidenceKind: 'composed' })),
|
|
108
|
+
adapterDiagnostics: [...input.inventoryDiagnostics], evidenceMode: 'composed', runtimeObservation: { observed: false, detail: 'A composition provider supplied a resolved tree; no runtime was started.' }, metadataCoverage: input.metadataCoverage, installedPackages: input.installedPackages
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const resolved = await tryDshDump(input);
|
|
112
|
+
if (resolved !== undefined) {
|
|
113
|
+
return {
|
|
114
|
+
profileDir: input.profileDir,
|
|
115
|
+
rows: resolved.rows,
|
|
116
|
+
adapterDiagnostics: [...input.inventoryDiagnostics, ...resolved.diagnostics],
|
|
117
|
+
evidenceMode: 'composed',
|
|
118
|
+
runtimeObservation: { observed: false, detail: 'dump-config composes configuration without starting third-party runtime code.' },
|
|
119
|
+
metadataCoverage: input.metadataCoverage,
|
|
120
|
+
installedPackages: input.installedPackages,
|
|
121
|
+
...inventoryFacts(input)
|
|
76
122
|
};
|
|
77
123
|
}
|
|
78
124
|
const rows = [];
|
|
79
|
-
const
|
|
125
|
+
const facts = inventoryFacts(input);
|
|
126
|
+
const adapterDiagnostics = [...input.inventoryDiagnostics, runtimeUnavailableDiagnostic()];
|
|
80
127
|
for (const file of input.files) {
|
|
81
128
|
if (file.relativePath !== 'cordis.yml' && file.relativePath !== 'cordis.patch.yml')
|
|
82
129
|
continue;
|
|
@@ -87,5 +134,5 @@ export async function resolveComposition(input, provider) {
|
|
|
87
134
|
adapterDiagnostics.push(parseFailedDiagnostic(file, error));
|
|
88
135
|
}
|
|
89
136
|
}
|
|
90
|
-
return { profileDir: input.profileDir, rows, adapterDiagnostics };
|
|
137
|
+
return { profileDir: input.profileDir, rows, ...facts, installedPackages: input.installedPackages, adapterDiagnostics, evidenceMode: 'static', runtimeObservation: { observed: false, detail: 'Only allow-listed static metadata was inspected.' }, metadataCoverage: input.metadataCoverage };
|
|
91
138
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function rowEvidence(row) {
|
|
2
|
+
return {
|
|
3
|
+
source: row.source,
|
|
4
|
+
subject: row.id ?? row.name ?? 'anonymous-row',
|
|
5
|
+
detail: `${row.layer === undefined ? 'resolved row' : `layer ${row.layer}`} ${row.replacement === true ? 'replaces the previous row' : 'declared'}`,
|
|
6
|
+
evidenceKind: row.evidenceKind
|
|
7
|
+
};
|
|
8
|
+
}
|
package/dist/core/diff.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
+
import { compare as semverCompare } from 'semver';
|
|
2
|
+
import { isSnapshotV2, legacyPlugins } from './snapshot.js';
|
|
1
3
|
function formatChange(change) {
|
|
2
4
|
const before = change.before === undefined ? '' : ` before=${JSON.stringify(change.before)}`;
|
|
3
5
|
const after = change.after === undefined ? '' : ` after=${JSON.stringify(change.after)}`;
|
|
4
6
|
return `- **${change.kind}** \`${change.key}\`${before}${after}`;
|
|
5
7
|
}
|
|
6
|
-
/** Renders the semantic diff without hiding the evidence in a summary-only line. */
|
|
7
8
|
export function renderSnapshotDiffMarkdown(diff) {
|
|
8
|
-
const sections = [
|
|
9
|
-
['Plugins', diff.pluginChanges],
|
|
10
|
-
['Cordis rows', diff.rowChanges],
|
|
11
|
-
['Tool hooks', diff.hookChanges],
|
|
12
|
-
['UI claims', diff.uiConflictChanges],
|
|
13
|
-
['Peer requirements', diff.peerChanges],
|
|
14
|
-
['Platform requirements', diff.platformChanges]
|
|
15
|
-
];
|
|
9
|
+
const sections = [['Plugins', diff.pluginChanges], ['Cordis rows', diff.rowChanges], ['Tool hooks', diff.hookChanges], ['UI claims', diff.uiConflictChanges], ['Peer requirements', diff.peerChanges], ['Platform requirements', diff.platformChanges]];
|
|
16
10
|
const body = sections.map(([title, values]) => `## ${title}\n\n${values.length === 0 ? 'No changes.' : values.map(formatChange).join('\n')}`).join('\n\n');
|
|
17
11
|
const riskItems = diff.upgradeRisk.items.length === 0 ? '- None.' : diff.upgradeRisk.items.map((item) => `- ${item}`).join('\n');
|
|
18
12
|
return `# DSH Composition Doctor Snapshot Diff\n\n${diff.upgradeRisk.summary}\n\n## Upgrade risk items\n\n${riskItems}\n\n${body}\n`;
|
|
@@ -20,50 +14,61 @@ export function renderSnapshotDiffMarkdown(diff) {
|
|
|
20
14
|
function compareVersions(left, right) {
|
|
21
15
|
if (left === undefined || right === undefined)
|
|
22
16
|
return undefined;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
try {
|
|
18
|
+
return semverCompare(left, right, { includePrerelease: true });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
27
21
|
return undefined;
|
|
28
|
-
|
|
29
|
-
if (a[index] !== b[index])
|
|
30
|
-
return a[index] < b[index] ? -1 : 1;
|
|
31
|
-
return 0;
|
|
22
|
+
}
|
|
32
23
|
}
|
|
33
|
-
function changes(before, after,
|
|
34
|
-
const previous = new Map(before.map((item) => [
|
|
35
|
-
const next = new Map(after.map((item) => [
|
|
24
|
+
function changes(before, after, beforeKey, afterKey = beforeKey) {
|
|
25
|
+
const previous = new Map(before.map((item) => [beforeKey(item), item]));
|
|
26
|
+
const next = new Map(after.map((item) => [afterKey(item), item]));
|
|
36
27
|
const result = [];
|
|
37
|
-
for (const
|
|
38
|
-
const left = previous.get(
|
|
39
|
-
const right = next.get(
|
|
28
|
+
for (const key of [...new Set([...previous.keys(), ...next.keys()])].sort()) {
|
|
29
|
+
const left = previous.get(key);
|
|
30
|
+
const right = next.get(key);
|
|
40
31
|
if (left === undefined)
|
|
41
|
-
result.push({ kind: 'added', key
|
|
32
|
+
result.push({ kind: 'added', key, after: right });
|
|
42
33
|
else if (right === undefined)
|
|
43
|
-
result.push({ kind: 'removed', key
|
|
34
|
+
result.push({ kind: 'removed', key, before: left });
|
|
44
35
|
else if (JSON.stringify(left) !== JSON.stringify(right))
|
|
45
|
-
result.push({ kind: 'changed', key
|
|
36
|
+
result.push({ kind: 'changed', key, before: left, after: right });
|
|
46
37
|
}
|
|
47
38
|
return result;
|
|
48
39
|
}
|
|
40
|
+
function normalize(snapshot) {
|
|
41
|
+
if (isSnapshotV2(snapshot)) {
|
|
42
|
+
return {
|
|
43
|
+
plugins: legacyPlugins(snapshot), rows: snapshot.rows, hooks: snapshot.hooks, uiClaims: snapshot.uiClaims,
|
|
44
|
+
peers: snapshot.packages.map((item) => ({ packageName: item.name, source: item.packageJsonSource, dsh: item.peerDsh, cordis: item.peerCordis, node: item.engineNode })),
|
|
45
|
+
platforms: snapshot.packages.map((item) => ({ packageName: item.name, source: item.packageJsonSource, supported: item.platform })), identityPrefix: ''
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { plugins: snapshot.plugins, rows: snapshot.rows, hooks: snapshot.hooks, uiClaims: snapshot.uiClaims, peers: snapshot.peers, platforms: snapshot.platforms, identityPrefix: `${snapshot.profile.path}:${snapshot.profile.packageName ?? ''}:` };
|
|
49
|
+
}
|
|
49
50
|
export function diffSnapshots(before, after) {
|
|
50
|
-
const
|
|
51
|
+
const left = normalize(before);
|
|
52
|
+
const right = normalize(after);
|
|
53
|
+
const pluginKey = (prefix, item) => `${prefix}${item.name}:${item.source}`;
|
|
54
|
+
const pluginChanges = changes(left.plugins, right.plugins, (item) => pluginKey(left.identityPrefix, item), (item) => pluginKey(right.identityPrefix, item)).map((change) => {
|
|
55
|
+
const name = change.before?.name ?? change.after?.name ?? change.key;
|
|
51
56
|
if (change.kind !== 'changed')
|
|
52
|
-
return { ...change, name
|
|
57
|
+
return { ...change, name };
|
|
53
58
|
const comparison = compareVersions(change.before?.version, change.after?.version);
|
|
54
|
-
return { ...change, name
|
|
59
|
+
return { ...change, name, kind: comparison === undefined ? 'changed' : comparison < 0 ? 'upgraded' : comparison > 0 ? 'downgraded' : 'changed' };
|
|
55
60
|
});
|
|
56
|
-
const rowChanges = changes(
|
|
57
|
-
const hookChanges = changes(
|
|
58
|
-
const uiConflictChanges = changes(
|
|
59
|
-
const peerChanges = changes(
|
|
60
|
-
const platformChanges = changes(
|
|
61
|
+
const rowChanges = changes(left.rows, right.rows, (item) => `${item.source}:${item.id ?? item.name ?? ''}`);
|
|
62
|
+
const hookChanges = changes(left.hooks, right.hooks, (item) => `${item.source}:${item.hook}:${item.packageName ?? ''}`);
|
|
63
|
+
const uiConflictChanges = changes(left.uiClaims, right.uiClaims, (item) => `${item.kind}:${item.value}:${item.source}`);
|
|
64
|
+
const peerChanges = changes(left.peers, right.peers, (item) => `${item.source ?? ''}:${item.packageName ?? ''}`);
|
|
65
|
+
const platformChanges = changes(left.platforms, right.platforms, (item) => `${item.source ?? ''}:${item.packageName ?? ''}`);
|
|
61
66
|
const items = pluginChanges.filter((change) => change.kind === 'downgraded' || change.kind === 'upgraded').map((change) => `${change.kind} plugin ${change.name}`);
|
|
62
67
|
if (hookChanges.length > 0)
|
|
63
|
-
items.push('hook registrations changed;
|
|
68
|
+
items.push('hook registrations changed; runtime ordering remains unverified');
|
|
64
69
|
if (uiConflictChanges.length > 0)
|
|
65
70
|
items.push('UI claims changed; verify ownership conflicts before upgrade');
|
|
66
71
|
if (peerChanges.length > 0 || platformChanges.length > 0)
|
|
67
72
|
items.push('peer or platform requirements changed; verify supported runtime facts');
|
|
68
|
-
return { pluginChanges, rowChanges, hookChanges, uiConflictChanges, peerChanges, platformChanges, upgradeRisk: { summary: items.length === 0 ? 'No
|
|
73
|
+
return { pluginChanges, rowChanges, hookChanges, uiConflictChanges, peerChanges, platformChanges, upgradeRisk: { summary: items.length === 0 ? 'No risk indicators changed within the evidence covered by these snapshots.' : `Risk indicators changed: ${items.join('; ')}. No migration was executed.`, items } };
|
|
69
74
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
function rowsFrom(value) {
|
|
6
|
+
if (Array.isArray(value))
|
|
7
|
+
return value;
|
|
8
|
+
const object = record(value);
|
|
9
|
+
if (object === undefined)
|
|
10
|
+
return [];
|
|
11
|
+
for (const key of ['rows', 'plugins', 'composition']) {
|
|
12
|
+
if (Array.isArray(object[key]))
|
|
13
|
+
return object[key];
|
|
14
|
+
}
|
|
15
|
+
const config = record(object.config);
|
|
16
|
+
if (config !== undefined && Array.isArray(config.plugins))
|
|
17
|
+
return config.plugins;
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
function configKeys(value) {
|
|
21
|
+
const object = record(value);
|
|
22
|
+
return object === undefined ? undefined : Object.keys(object).sort();
|
|
23
|
+
}
|
|
24
|
+
function provenanceByRow(stdout) {
|
|
25
|
+
const result = [];
|
|
26
|
+
let current;
|
|
27
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
28
|
+
const comment = line.match(/^\s*#\s*==\s*(.+?)\s*$/);
|
|
29
|
+
if (comment !== null) {
|
|
30
|
+
current = comment[1];
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (current !== undefined && /^-\s/.test(line))
|
|
34
|
+
result.push(current);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
function provenanceParts(label) {
|
|
39
|
+
if (label === undefined)
|
|
40
|
+
return {};
|
|
41
|
+
const parts = label.split(/,\s*patched by\s+/);
|
|
42
|
+
const source = parts[0];
|
|
43
|
+
const patches = parts.slice(1).flatMap((part) => part.split(/,\s+/)).filter(Boolean);
|
|
44
|
+
return { source, provenance: [source, ...patches] };
|
|
45
|
+
}
|
|
46
|
+
export function parseDshDump(stdout, stderr = '') {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(stdout);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
parsed = parse(stdout, {
|
|
54
|
+
customTags: [
|
|
55
|
+
{ tag: 'tag:yaml.org,2002:js', resolve: (value) => value },
|
|
56
|
+
{ tag: 'tag:yaml.org,2002:js/function', resolve: (value) => value }
|
|
57
|
+
]
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
rows: [],
|
|
64
|
+
diagnostics: [{
|
|
65
|
+
id: 'composed-dump-parse-failed', severity: 'warning', title: 'Composed DSH dump could not be parsed',
|
|
66
|
+
evidence: [{ source: '<DSH dump>', detail: error instanceof Error ? error.message : String(error), evidenceKind: 'composed' }],
|
|
67
|
+
explanation: 'The public dump-config command returned output that was not valid JSON or YAML.',
|
|
68
|
+
remediation: 'Use a verified DSH release whose public dump-config output is supported by this adapter.'
|
|
69
|
+
}]
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const provenanceHints = provenanceByRow(stdout);
|
|
73
|
+
const legacyProvenanceHints = stdout.split(/\r?\n/).flatMap((line) => {
|
|
74
|
+
const match = line.match(/^\s*#\s*(?:source|from|file|layer)\s*[:=]\s*(.+?)\s*$/i);
|
|
75
|
+
return match === null ? [] : [match[1]];
|
|
76
|
+
});
|
|
77
|
+
const rawRows = rowsFrom(parsed);
|
|
78
|
+
const seenIds = new Map();
|
|
79
|
+
const rows = rawRows.flatMap((entry, index) => {
|
|
80
|
+
const object = record(entry);
|
|
81
|
+
if (object === undefined)
|
|
82
|
+
return [];
|
|
83
|
+
const provenance = provenanceParts(typeof object.source === 'string' ? object.source : provenanceHints[index] ?? legacyProvenanceHints[index]);
|
|
84
|
+
const source = typeof object.source === 'string' ? object.source : provenance.source ?? '<DSH dump>';
|
|
85
|
+
const config = object.config;
|
|
86
|
+
const row = {
|
|
87
|
+
...(typeof object.id === 'string' ? { id: object.id } : {}),
|
|
88
|
+
...(typeof object.name === 'string' ? { name: object.name } : typeof object.plugin === 'string' ? { name: object.plugin } : {}),
|
|
89
|
+
source,
|
|
90
|
+
...(config === undefined ? {} : { config, ...(configKeys(config) === undefined ? {} : { configKeys: configKeys(config) }) }),
|
|
91
|
+
evidenceKind: 'composed',
|
|
92
|
+
...(typeof object.layer === 'string' ? { layer: object.layer } : {}),
|
|
93
|
+
...(typeof object.layerOrder === 'number' ? { layerOrder: object.layerOrder } : {}),
|
|
94
|
+
...(provenance.provenance === undefined ? {} : { provenance: provenance.provenance }),
|
|
95
|
+
...(object.replacement === true || object.overridden === true || (provenance.provenance !== undefined && provenance.provenance.length > 1) || (typeof object.id === 'string' && seenIds.has(object.id) && seenIds.get(object.id) !== source) ? { replacement: true } : {})
|
|
96
|
+
};
|
|
97
|
+
if (row.id !== undefined)
|
|
98
|
+
seenIds.set(row.id, row.source);
|
|
99
|
+
return [row];
|
|
100
|
+
});
|
|
101
|
+
const diagnostics = [];
|
|
102
|
+
const unmatched = stderr.match(/(?:patch[^\r\n]*(?:unmatched|unknown|not found)|(?:unmatched|unknown|not found)[^\r\n]*(?:patch|row|target))[^\r\n]*/gi) ?? [];
|
|
103
|
+
for (const detail of unmatched)
|
|
104
|
+
diagnostics.push({
|
|
105
|
+
id: 'unmatched-patch-target', severity: 'warning', title: 'Resolved patch target did not match a row',
|
|
106
|
+
evidence: [{ source: '<DSH stderr>', detail, evidenceKind: 'composed' }],
|
|
107
|
+
explanation: 'The public DSH dump reported a patch target that did not match a resolved row.',
|
|
108
|
+
remediation: 'Verify the patch row id against the selected bundle/profile composition.'
|
|
109
|
+
});
|
|
110
|
+
return { rows, diagnostics };
|
|
111
|
+
}
|