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/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
- function download(document, content, filename, mime) {
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
- link.href = URL.createObjectURL(new Blob([content], { type: mime }));
163
+ const objectUrl = hooks.createObjectURL(blob);
164
+ link.hidden = true;
165
+ link.href = objectUrl;
118
166
  link.download = filename;
119
- link.click();
120
- URL.revokeObjectURL(link.href);
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 fetchReport().then((report) => download(document, `${JSON.stringify(report, null, 2)}
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 fetchReport().then((report) => download(document, renderMarkdown(report), "dsh-composition-doctor-report.md", "text/markdown")).catch(() => void 0);
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
- status.textContent = `Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info. Conflict graph: ${model.graph.nodes.length} evidence nodes.`;
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
- item.textContent = `[${diagnostic.severity}] ${diagnostic.title}`;
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((error) => {
162
- status.textContent = error instanceof Error ? error.message : translate("unavailable");
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: () => createReportView()
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 \u7EC4\u5408\u533B\u751F", exportJson: "\u5BFC\u51FA JSON", exportMarkdown: "\u5BFC\u51FA Markdown" },
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
- }, () => createReportView(void 0, t)));
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
- function runtimeUnavailableDiagnostic() {
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: 'Static YAML was read, but actual resolved composition requires a public provider.',
9
- remediation: 'Supply a public ResolvedCompositionProvider to resolve runtime composition.'
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 = parse(file.text);
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: 'resolved' })),
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 adapterDiagnostics = [runtimeUnavailableDiagnostic()];
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
- const parse = (value) => /^(\d+)\.(\d+)\.(\d+)/.exec(value)?.slice(1).map(Number);
24
- const a = parse(left);
25
- const b = parse(right);
26
- if (a === undefined || b === undefined)
17
+ try {
18
+ return semverCompare(left, right, { includePrerelease: true });
19
+ }
20
+ catch {
27
21
  return undefined;
28
- for (let index = 0; index < 3; index += 1)
29
- if (a[index] !== b[index])
30
- return a[index] < b[index] ? -1 : 1;
31
- return 0;
22
+ }
32
23
  }
33
- function changes(before, after, key) {
34
- const previous = new Map(before.map((item) => [key(item), item]));
35
- const next = new Map(after.map((item) => [key(item), item]));
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 itemKey of [...new Set([...previous.keys(), ...next.keys()])].sort()) {
38
- const left = previous.get(itemKey);
39
- const right = next.get(itemKey);
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: itemKey, after: right });
32
+ result.push({ kind: 'added', key, after: right });
42
33
  else if (right === undefined)
43
- result.push({ kind: 'removed', key: itemKey, before: left });
34
+ result.push({ kind: 'removed', key, before: left });
44
35
  else if (JSON.stringify(left) !== JSON.stringify(right))
45
- result.push({ kind: 'changed', key: itemKey, before: left, after: right });
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 pluginChanges = changes(before.plugins, after.plugins, (item) => item.name).map((change) => {
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: change.key };
57
+ return { ...change, name };
53
58
  const comparison = compareVersions(change.before?.version, change.after?.version);
54
- return { ...change, name: change.key, kind: comparison === undefined ? 'changed' : comparison < 0 ? 'upgraded' : comparison > 0 ? 'downgraded' : 'changed' };
59
+ return { ...change, name, kind: comparison === undefined ? 'changed' : comparison < 0 ? 'upgraded' : comparison > 0 ? 'downgraded' : 'changed' };
55
60
  });
56
- const rowChanges = changes(before.rows, after.rows, (item) => `${item.source}:${item.id ?? item.name ?? ''}`);
57
- const hookChanges = changes(before.hooks, after.hooks, (item) => `${item.source}:${item.hook}:${item.packageName ?? ''}`);
58
- const uiConflictChanges = changes(before.uiClaims, after.uiClaims, (item) => `${item.kind}:${item.value}:${item.source}`);
59
- const peerChanges = changes(before.peers, after.peers, (item) => `${item.source}:${item.packageName}`);
60
- const platformChanges = changes(before.platforms, after.platforms, (item) => `${item.source}:${item.packageName}`);
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; verify runtime ordering in an isolated fixture');
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 upgrade risks detected from snapshot metadata.' : `Upgrade risk: ${items.join('; ')}. No migration was executed.`, items } };
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
+ }