dsh-composition-doctor 0.2.1 → 0.3.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.
@@ -35,7 +35,55 @@ export function toReportViewModel(report) {
35
35
  const counts = { info: 0, warning: 0, error: 0 };
36
36
  for (const diagnostic of report.diagnostics)
37
37
  counts[diagnostic.severity] += 1;
38
- return { report, counts, graph: buildConflictGraph(report) };
38
+ return {
39
+ report, counts, graph: buildConflictGraph(report),
40
+ compositionGraph: { nodes: report.compositionFacts?.nodes ?? [], edges: report.compositionFacts?.edges ?? [] }
41
+ };
42
+ }
43
+ export function compositionNodeDetail(report, nodeId) {
44
+ const facts = report.compositionFacts;
45
+ const node = facts?.nodes.find((candidate) => candidate.id === nodeId);
46
+ if (facts === undefined || node === undefined)
47
+ return undefined;
48
+ const relatedRows = facts.rows.filter((candidate) => {
49
+ if (node.rowKey !== undefined)
50
+ return candidate.key === node.rowKey;
51
+ if (node.entity === 'bundle')
52
+ return candidate.packageName === node.packageName || candidate.provenance.some((step) => step.source === node.label);
53
+ if (node.entity === 'source')
54
+ return candidate.source === node.source || candidate.provenance.some((step) => step.source === node.source);
55
+ if (node.entity === 'layer')
56
+ return candidate.layer === node.layer && (node.layerOrder === undefined || candidate.layerOrder === node.layerOrder);
57
+ if (node.entity === 'diagnostic')
58
+ return node.diagnosticId !== undefined && candidate.relatedDiagnosticIds.includes(node.diagnosticId);
59
+ return false;
60
+ });
61
+ const fields = [
62
+ { label: 'entity', value: node.entity },
63
+ { label: 'source', value: node.source ?? node.label },
64
+ { label: 'evidence', value: node.evidenceMode ?? 'unknown' }
65
+ ];
66
+ if (node.layer !== undefined)
67
+ fields.push({ label: 'layer', value: node.layer });
68
+ if (node.layerOrder !== undefined)
69
+ fields.push({ label: 'layer order', value: String(node.layerOrder) });
70
+ if (node.packageName !== undefined)
71
+ fields.push({ label: 'package', value: `${node.packageName}${node.packageVersion === undefined ? '' : `@${node.packageVersion}`}` });
72
+ if (relatedRows.length > 0) {
73
+ fields.push({ label: 'related rows', value: relatedRows.map((row) => row.id ?? row.name ?? row.key).join(', ') });
74
+ fields.push({ label: 'config keys', value: relatedRows.map((row) => `${row.id ?? row.key}: ${row.configKeys.length === 0 ? '(none observed)' : row.configKeys.join(', ')}`).join('\n') });
75
+ fields.push({ label: 'replacement', value: relatedRows.map((row) => `${row.id ?? row.key}: ${row.replacement.state} (${row.replacement.basis})`).join('\n') });
76
+ fields.push({ label: 'provenance', value: relatedRows.map((row) => `${row.id ?? row.key}: ${row.provenance.length === 0 ? 'unknown' : row.provenance.map((item) => `${item.relation}: ${item.source} (${item.basis})`).join(' → ')}`).join('\n') });
77
+ }
78
+ const relatedDiagnosticIds = new Set(relatedRows.flatMap((row) => row.relatedDiagnosticIds));
79
+ const rowDiagnostics = report.diagnostics.filter((item) => relatedDiagnosticIds.has(item.id));
80
+ const nodeDiagnostic = node.diagnosticId === undefined ? [] : report.diagnostics.filter((item) => item.id === node.diagnosticId);
81
+ return {
82
+ title: node.label,
83
+ fields,
84
+ diagnostics: [...new Map([...rowDiagnostics, ...nodeDiagnostic].map((item) => [item.id, item])).values()],
85
+ unknown: [...new Set([...relatedRows.flatMap((row) => row.unknown), ...facts.unknownSurfaces])]
86
+ };
39
87
  }
40
88
  function renderConflictGraph(document, graph) {
41
89
  const section = document.createElement('section');
@@ -59,6 +107,99 @@ function renderConflictGraph(document, graph) {
59
107
  section.append(list);
60
108
  return section;
61
109
  }
110
+ export function createCompositionExplorer(document, report) {
111
+ const section = document.createElement('section');
112
+ section.setAttribute('aria-labelledby', 'dsh-composition-doctor-explorer');
113
+ const heading = document.createElement('h3');
114
+ heading.id = 'dsh-composition-doctor-explorer';
115
+ heading.textContent = 'Composition Explorer';
116
+ section.append(heading);
117
+ const facts = report.compositionFacts;
118
+ const graph = facts === undefined ? { nodes: [], edges: [] } : { nodes: facts.nodes, edges: facts.edges };
119
+ if (graph.nodes.length === 0) {
120
+ const message = document.createElement('p');
121
+ message.textContent = 'Composition provenance is unavailable in this legacy report.';
122
+ section.append(message);
123
+ }
124
+ else {
125
+ const list = document.createElement('ul');
126
+ for (const node of graph.nodes.filter((value) => value.entity !== 'diagnostic')) {
127
+ const item = document.createElement('li');
128
+ const select = button(document, `${node.entity}: ${node.label}`, () => {
129
+ const detail = compositionNodeDetail(report, node.id);
130
+ if (detail === undefined)
131
+ return;
132
+ const panel = section.querySelector('[data-composition-detail]');
133
+ if (panel === null)
134
+ return;
135
+ panel.replaceChildren();
136
+ const title = document.createElement('h4');
137
+ title.textContent = detail.title;
138
+ panel.append(title);
139
+ const fields = document.createElement('dl');
140
+ for (const field of detail.fields) {
141
+ const term = document.createElement('dt');
142
+ term.textContent = field.label;
143
+ const value = document.createElement('dd');
144
+ value.textContent = field.value;
145
+ fields.append(term, value);
146
+ }
147
+ panel.append(fields);
148
+ const diagnostics = document.createElement('ul');
149
+ for (const diagnostic of detail.diagnostics) {
150
+ const entry = document.createElement('li');
151
+ entry.textContent = `[${diagnostic.severity}] ${diagnostic.title}: ${diagnostic.explanation}`;
152
+ diagnostics.append(entry);
153
+ }
154
+ if (detail.diagnostics.length > 0)
155
+ panel.append(diagnostics);
156
+ const unknown = document.createElement('ul');
157
+ for (const value of detail.unknown) {
158
+ const entry = document.createElement('li');
159
+ entry.textContent = value;
160
+ unknown.append(entry);
161
+ }
162
+ if (detail.unknown.length > 0) {
163
+ const unknownTitle = document.createElement('h4');
164
+ unknownTitle.textContent = 'Unknown / not modelled';
165
+ panel.append(unknownTitle, unknown);
166
+ }
167
+ });
168
+ select.dataset.compositionNode = node.id;
169
+ item.append(select);
170
+ const outgoing = graph.edges.filter((edge) => edge.from === node.id);
171
+ if (outgoing.length > 0) {
172
+ const relationships = document.createElement('ul');
173
+ for (const edge of outgoing) {
174
+ const target = graph.nodes.find((candidate) => candidate.id === edge.to);
175
+ if (target === undefined || target.entity === 'diagnostic')
176
+ continue;
177
+ const relationship = document.createElement('li');
178
+ relationship.textContent = `${edge.relation} → ${target.label} (${edge.basis}; ${edge.evidenceMode})`;
179
+ relationships.append(relationship);
180
+ }
181
+ item.append(relationships);
182
+ }
183
+ list.append(item);
184
+ }
185
+ section.append(list);
186
+ const detail = document.createElement('section');
187
+ detail.dataset.compositionDetail = 'true';
188
+ detail.setAttribute('aria-live', 'polite');
189
+ detail.textContent = 'Select a bundle, source, layer, or row to inspect its evidence.';
190
+ section.append(detail);
191
+ const unknownList = document.createElement('ul');
192
+ for (const value of facts?.unknownSurfaces ?? []) {
193
+ const entry = document.createElement('li');
194
+ entry.textContent = value;
195
+ unknownList.append(entry);
196
+ }
197
+ const unknownTitle = document.createElement('h4');
198
+ unknownTitle.textContent = 'Other surfaces';
199
+ section.append(unknownTitle, unknownList);
200
+ }
201
+ return section;
202
+ }
62
203
  function isReport(value) {
63
204
  if (value === null || typeof value !== 'object')
64
205
  return false;
@@ -145,6 +286,7 @@ export function createReportView(document = globalThis.document, translate = (ke
145
286
  status.textContent = `Generated: ${report.generatedAt}. Profile: ${report.profileDir}. Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info.`;
146
287
  status.append(document.createTextNode(' '), badge);
147
288
  root.append(renderConflictGraph(document, model.graph));
289
+ root.append(createCompositionExplorer(document, report));
148
290
  const list = document.createElement('ul');
149
291
  for (const diagnostic of report.diagnostics) {
150
292
  const item = document.createElement('li');
package/dist/client.js CHANGED
@@ -106,7 +106,48 @@ window.__ModuleLoader__.load({
106
106
  function toReportViewModel(report) {
107
107
  const counts = { info: 0, warning: 0, error: 0 };
108
108
  for (const diagnostic of report.diagnostics) counts[diagnostic.severity] += 1;
109
- return { report, counts, graph: buildConflictGraph(report) };
109
+ return {
110
+ report,
111
+ counts,
112
+ graph: buildConflictGraph(report),
113
+ compositionGraph: { nodes: report.compositionFacts?.nodes ?? [], edges: report.compositionFacts?.edges ?? [] }
114
+ };
115
+ }
116
+ function compositionNodeDetail(report, nodeId) {
117
+ const facts = report.compositionFacts;
118
+ const node = facts?.nodes.find((candidate) => candidate.id === nodeId);
119
+ if (facts === void 0 || node === void 0) return void 0;
120
+ const relatedRows = facts.rows.filter((candidate) => {
121
+ if (node.rowKey !== void 0) return candidate.key === node.rowKey;
122
+ if (node.entity === "bundle") return candidate.packageName === node.packageName || candidate.provenance.some((step) => step.source === node.label);
123
+ if (node.entity === "source") return candidate.source === node.source || candidate.provenance.some((step) => step.source === node.source);
124
+ if (node.entity === "layer") return candidate.layer === node.layer && (node.layerOrder === void 0 || candidate.layerOrder === node.layerOrder);
125
+ if (node.entity === "diagnostic") return node.diagnosticId !== void 0 && candidate.relatedDiagnosticIds.includes(node.diagnosticId);
126
+ return false;
127
+ });
128
+ const fields = [
129
+ { label: "entity", value: node.entity },
130
+ { label: "source", value: node.source ?? node.label },
131
+ { label: "evidence", value: node.evidenceMode ?? "unknown" }
132
+ ];
133
+ if (node.layer !== void 0) fields.push({ label: "layer", value: node.layer });
134
+ if (node.layerOrder !== void 0) fields.push({ label: "layer order", value: String(node.layerOrder) });
135
+ if (node.packageName !== void 0) fields.push({ label: "package", value: `${node.packageName}${node.packageVersion === void 0 ? "" : `@${node.packageVersion}`}` });
136
+ if (relatedRows.length > 0) {
137
+ fields.push({ label: "related rows", value: relatedRows.map((row) => row.id ?? row.name ?? row.key).join(", ") });
138
+ fields.push({ label: "config keys", value: relatedRows.map((row) => `${row.id ?? row.key}: ${row.configKeys.length === 0 ? "(none observed)" : row.configKeys.join(", ")}`).join("\n") });
139
+ fields.push({ label: "replacement", value: relatedRows.map((row) => `${row.id ?? row.key}: ${row.replacement.state} (${row.replacement.basis})`).join("\n") });
140
+ fields.push({ label: "provenance", value: relatedRows.map((row) => `${row.id ?? row.key}: ${row.provenance.length === 0 ? "unknown" : row.provenance.map((item) => `${item.relation}: ${item.source} (${item.basis})`).join(" \u2192 ")}`).join("\n") });
141
+ }
142
+ const relatedDiagnosticIds = new Set(relatedRows.flatMap((row) => row.relatedDiagnosticIds));
143
+ const rowDiagnostics = report.diagnostics.filter((item) => relatedDiagnosticIds.has(item.id));
144
+ const nodeDiagnostic = node.diagnosticId === void 0 ? [] : report.diagnostics.filter((item) => item.id === node.diagnosticId);
145
+ return {
146
+ title: node.label,
147
+ fields,
148
+ diagnostics: [...new Map([...rowDiagnostics, ...nodeDiagnostic].map((item) => [item.id, item])).values()],
149
+ unknown: [.../* @__PURE__ */ new Set([...relatedRows.flatMap((row) => row.unknown), ...facts.unknownSurfaces])]
150
+ };
110
151
  }
111
152
  function renderConflictGraph(document, graph) {
112
153
  const section = document.createElement("section");
@@ -130,6 +171,94 @@ window.__ModuleLoader__.load({
130
171
  section.append(list);
131
172
  return section;
132
173
  }
174
+ function createCompositionExplorer(document, report) {
175
+ const section = document.createElement("section");
176
+ section.setAttribute("aria-labelledby", "dsh-composition-doctor-explorer");
177
+ const heading = document.createElement("h3");
178
+ heading.id = "dsh-composition-doctor-explorer";
179
+ heading.textContent = "Composition Explorer";
180
+ section.append(heading);
181
+ const facts = report.compositionFacts;
182
+ const graph = facts === void 0 ? { nodes: [], edges: [] } : { nodes: facts.nodes, edges: facts.edges };
183
+ if (graph.nodes.length === 0) {
184
+ const message = document.createElement("p");
185
+ message.textContent = "Composition provenance is unavailable in this legacy report.";
186
+ section.append(message);
187
+ } else {
188
+ const list = document.createElement("ul");
189
+ for (const node of graph.nodes.filter((value) => value.entity !== "diagnostic")) {
190
+ const item = document.createElement("li");
191
+ const select = button(document, `${node.entity}: ${node.label}`, () => {
192
+ const detail2 = compositionNodeDetail(report, node.id);
193
+ if (detail2 === void 0) return;
194
+ const panel = section.querySelector("[data-composition-detail]");
195
+ if (panel === null) return;
196
+ panel.replaceChildren();
197
+ const title = document.createElement("h4");
198
+ title.textContent = detail2.title;
199
+ panel.append(title);
200
+ const fields = document.createElement("dl");
201
+ for (const field of detail2.fields) {
202
+ const term = document.createElement("dt");
203
+ term.textContent = field.label;
204
+ const value = document.createElement("dd");
205
+ value.textContent = field.value;
206
+ fields.append(term, value);
207
+ }
208
+ panel.append(fields);
209
+ const diagnostics = document.createElement("ul");
210
+ for (const diagnostic of detail2.diagnostics) {
211
+ const entry = document.createElement("li");
212
+ entry.textContent = `[${diagnostic.severity}] ${diagnostic.title}: ${diagnostic.explanation}`;
213
+ diagnostics.append(entry);
214
+ }
215
+ if (detail2.diagnostics.length > 0) panel.append(diagnostics);
216
+ const unknown = document.createElement("ul");
217
+ for (const value of detail2.unknown) {
218
+ const entry = document.createElement("li");
219
+ entry.textContent = value;
220
+ unknown.append(entry);
221
+ }
222
+ if (detail2.unknown.length > 0) {
223
+ const unknownTitle2 = document.createElement("h4");
224
+ unknownTitle2.textContent = "Unknown / not modelled";
225
+ panel.append(unknownTitle2, unknown);
226
+ }
227
+ });
228
+ select.dataset.compositionNode = node.id;
229
+ item.append(select);
230
+ const outgoing = graph.edges.filter((edge) => edge.from === node.id);
231
+ if (outgoing.length > 0) {
232
+ const relationships = document.createElement("ul");
233
+ for (const edge of outgoing) {
234
+ const target = graph.nodes.find((candidate) => candidate.id === edge.to);
235
+ if (target === void 0 || target.entity === "diagnostic") continue;
236
+ const relationship = document.createElement("li");
237
+ relationship.textContent = `${edge.relation} \u2192 ${target.label} (${edge.basis}; ${edge.evidenceMode})`;
238
+ relationships.append(relationship);
239
+ }
240
+ item.append(relationships);
241
+ }
242
+ list.append(item);
243
+ }
244
+ section.append(list);
245
+ const detail = document.createElement("section");
246
+ detail.dataset.compositionDetail = "true";
247
+ detail.setAttribute("aria-live", "polite");
248
+ detail.textContent = "Select a bundle, source, layer, or row to inspect its evidence.";
249
+ section.append(detail);
250
+ const unknownList = document.createElement("ul");
251
+ for (const value of facts?.unknownSurfaces ?? []) {
252
+ const entry = document.createElement("li");
253
+ entry.textContent = value;
254
+ unknownList.append(entry);
255
+ }
256
+ const unknownTitle = document.createElement("h4");
257
+ unknownTitle.textContent = "Other surfaces";
258
+ section.append(unknownTitle, unknownList);
259
+ }
260
+ return section;
261
+ }
133
262
  function isReport(value) {
134
263
  if (value === null || typeof value !== "object") return false;
135
264
  const candidate = value;
@@ -220,6 +349,7 @@ window.__ModuleLoader__.load({
220
349
  status.textContent = `Generated: ${report.generatedAt}. Profile: ${report.profileDir}. Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info.`;
221
350
  status.append(document.createTextNode(" "), badge);
222
351
  root.append(renderConflictGraph(document, model.graph));
352
+ root.append(createCompositionExplorer(document, report));
223
353
  const list = document.createElement("ul");
224
354
  for (const diagnostic of report.diagnostics) {
225
355
  const item = document.createElement("li");
@@ -1,3 +1,209 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { basename, isAbsolute, relative, win32 } from 'node:path';
3
+ const unknownSurfaces = [
4
+ 'route ownership: not observed / not modelled',
5
+ 'slot ownership: not observed / not modelled',
6
+ 'runtime hook ownership: not observed / not modelled',
7
+ 'arbitrary dependencies: not modelled',
8
+ 'possible dependents: not modelled'
9
+ ];
10
+ function stableKey(value) {
11
+ return createHash('sha256').update(value).digest('hex').slice(0, 16);
12
+ }
13
+ /** Replace profile and external absolute paths before any composition fact is serialized. */
14
+ export function normalizeCompositionSource(source, profileDir) {
15
+ if (source.startsWith('<') && source.endsWith('>'))
16
+ return source;
17
+ const windowsAbsolute = /^[a-z]:[\\/]/i.test(source) || source.startsWith('\\\\');
18
+ const posixAbsolute = source.startsWith('/');
19
+ if (!windowsAbsolute && !posixAbsolute && !isAbsolute(source))
20
+ return source.replaceAll('\\', '/');
21
+ try {
22
+ if (windowsAbsolute || /^[a-z]:[\\/]/i.test(profileDir)) {
23
+ const relativePath = win32.relative(win32.resolve(profileDir), win32.resolve(source)).replaceAll('\\', '/');
24
+ if (relativePath === '')
25
+ return '<PROFILE>';
26
+ if (relativePath !== '..' && !relativePath.startsWith('../') && !/^[a-z]:/i.test(relativePath))
27
+ return `<PROFILE>/${relativePath}`;
28
+ }
29
+ else {
30
+ const relativePath = relative(profileDir, source).replaceAll('\\', '/');
31
+ if (relativePath === '')
32
+ return '<PROFILE>';
33
+ if (relativePath !== '..' && !relativePath.startsWith('../') && !isAbsolute(relativePath))
34
+ return `<PROFILE>/${relativePath}`;
35
+ }
36
+ }
37
+ catch { /* malformed paths are reduced to a basename below */ }
38
+ return `<EXTERNAL>/${basename(source.replaceAll('\\', '/'))}`;
39
+ }
40
+ function rowConfigKeys(row) {
41
+ if (row.configKeys !== undefined)
42
+ return [...new Set(row.configKeys)].sort();
43
+ if (row.config !== null && typeof row.config === 'object' && !Array.isArray(row.config))
44
+ return Object.keys(row.config).sort();
45
+ return [];
46
+ }
47
+ function matchingPackages(model, sources) {
48
+ const labels = new Set(sources);
49
+ return (model.installedPackages ?? []).filter((fact) => labels.has(fact.name));
50
+ }
51
+ function relatedDiagnostics(row, packageNames, diagnostics, profileDir) {
52
+ const sources = new Set([row.source, ...(row.provenance ?? [])].map((source) => normalizeCompositionSource(source, profileDir)));
53
+ return diagnostics.filter((diagnostic) => diagnostic.evidence.some((item) => (row.id !== undefined && item.subject === row.id) ||
54
+ packageNames.includes(item.packageName ?? '') ||
55
+ sources.has(item.source))).map((item) => item.id).sort();
56
+ }
57
+ export function buildCompositionFacts(model, diagnostics) {
58
+ const nodes = new Map();
59
+ const edgeMap = new Map();
60
+ const rows = [];
61
+ const packages = model.installedPackages ?? [];
62
+ const reportMode = model.evidenceMode ?? 'static';
63
+ const addNode = (node) => nodes.set(node.id, node);
64
+ const addEdge = (edge) => edgeMap.set(`${edge.from}\0${edge.relation}\0${edge.to}`, edge);
65
+ for (const packageFact of packages) {
66
+ const bundleId = `bundle:${encodeURIComponent(packageFact.name)}`;
67
+ addNode({ id: bundleId, entity: 'bundle', label: packageFact.name, source: normalizeCompositionSource(packageFact.packageJsonSource, model.profileDir), packageName: packageFact.name, ...(packageFact.installedVersion === undefined ? {} : { packageVersion: packageFact.installedVersion }), evidenceMode: 'static' });
68
+ }
69
+ for (const [index, row] of model.rows.entries()) {
70
+ const rowIdentity = row.id ?? row.name ?? `${normalizeCompositionSource(row.source, model.profileDir)}:${index}`;
71
+ const rowKey = `row:${stableKey(`${rowIdentity}|${normalizeCompositionSource(row.source, model.profileDir)}|${row.layer ?? ''}|${row.layerOrder ?? index}`)}`;
72
+ const chain = row.provenance === undefined ? [] : [...row.provenance];
73
+ const chainSources = chain.map((source) => normalizeCompositionSource(source, model.profileDir));
74
+ const source = normalizeCompositionSource(row.source, model.profileDir);
75
+ const directPackages = matchingPackages(model, chain.length > 0 ? chain : [row.source]);
76
+ const associatedPackage = directPackages.length === 1 ? directPackages[0] : undefined;
77
+ const replacement = row.replacement === true && row.replacementBasis !== 'derived'
78
+ ? { state: 'yes', basis: 'observed' }
79
+ : row.replacement === true
80
+ ? { state: 'unknown', basis: 'unknown' }
81
+ : row.replacement === false
82
+ ? { state: 'no', basis: 'observed' }
83
+ : { state: 'unknown', basis: 'unknown' };
84
+ const unknown = [];
85
+ if (chain.length === 0)
86
+ unknown.push('provenance chain is unavailable');
87
+ if (row.layer === undefined)
88
+ unknown.push('layer is unknown');
89
+ if (row.replacement === true)
90
+ unknown.push(row.replacementBasis !== 'derived'
91
+ ? 'field-level owner and prior config values are unknown'
92
+ : 'source chain indicates patching, but whole-row/config replacement, removed keys, and field owners are unknown');
93
+ if (row.evidenceKind === 'static')
94
+ unknown.push('final composed row is unknown; this is a static declaration');
95
+ if (model.runtimeObservation?.observed !== true)
96
+ unknown.push('runtime behavior was not observed; runtime-dependent values remain unknown/not-run');
97
+ const provenance = chain.length === 0
98
+ ? []
99
+ : chain.map((chainSource, chainIndex) => ({
100
+ source: chainSources[chainIndex],
101
+ sourceBasis: 'observed',
102
+ relation: (chainIndex === 0 ? 'introduced' : 'patched-by'),
103
+ basis: 'derived'
104
+ }));
105
+ const fact = {
106
+ entity: 'row', key: rowKey,
107
+ ...(row.id === undefined ? {} : { id: row.id }),
108
+ ...(row.name === undefined ? {} : { name: row.name }),
109
+ source,
110
+ sourceBasis: 'observed',
111
+ ...(row.layer === undefined ? {} : { layer: row.layer }),
112
+ ...(row.layerOrder === undefined ? {} : { layerOrder: row.layerOrder }),
113
+ provenance,
114
+ replacement,
115
+ configKeys: rowConfigKeys(row),
116
+ configKeysBasis: row.configKeys === undefined ? 'derived' : 'observed',
117
+ evidenceMode: row.evidenceKind,
118
+ ...(associatedPackage === undefined ? {} : { packageName: associatedPackage.name, ...(associatedPackage.installedVersion === undefined ? {} : { packageVersion: associatedPackage.installedVersion }) }),
119
+ relatedDiagnosticIds: relatedDiagnostics(row, directPackages.map((item) => item.name), diagnostics, model.profileDir),
120
+ unknown: [...new Set(unknown)].sort()
121
+ };
122
+ rows.push(fact);
123
+ addNode({ id: rowKey, entity: 'row', label: row.id ?? row.name ?? 'anonymous row', source, ...(row.layer === undefined ? {} : { layer: row.layer }), ...(row.layerOrder === undefined ? {} : { layerOrder: row.layerOrder }), ...(fact.packageName === undefined ? {} : { packageName: fact.packageName }), ...(fact.packageVersion === undefined ? {} : { packageVersion: fact.packageVersion }), evidenceMode: row.evidenceKind, rowKey });
124
+ if (row.layer !== undefined) {
125
+ const layerId = `layer:${stableKey(`${row.layer}:${row.layerOrder ?? ''}`)}`;
126
+ addNode({ id: layerId, entity: 'layer', label: row.layer, layer: row.layer, ...(row.layerOrder === undefined ? {} : { layerOrder: row.layerOrder }), evidenceMode: row.evidenceKind });
127
+ addEdge({ from: layerId, to: rowKey, relation: 'contains', basis: 'observed', evidenceMode: row.evidenceKind });
128
+ }
129
+ const linkSources = chain.length === 0 ? [row.source] : chain;
130
+ linkSources.forEach((label, chainIndex) => {
131
+ const normalized = normalizeCompositionSource(label, model.profileDir);
132
+ const matched = packages.filter((pkg) => pkg.name === label);
133
+ const packageFact = matched.length === 1 ? matched[0] : undefined;
134
+ const sourceId = `source:${stableKey(normalized)}`;
135
+ addNode({ id: sourceId, entity: 'source', label: normalized, source: normalized, evidenceMode: row.evidenceKind });
136
+ if (packageFact !== undefined) {
137
+ const bundleId = `bundle:${encodeURIComponent(packageFact.name)}`;
138
+ addNode({ id: bundleId, entity: 'bundle', label: packageFact.name, source: normalizeCompositionSource(packageFact.packageJsonSource, model.profileDir), packageName: packageFact.name, ...(packageFact.installedVersion === undefined ? {} : { packageVersion: packageFact.installedVersion }), evidenceMode: 'static' });
139
+ addEdge({ from: bundleId, to: sourceId, relation: 'package-source', basis: 'derived', evidenceMode: row.evidenceKind });
140
+ }
141
+ addEdge({
142
+ from: sourceId,
143
+ to: rowKey,
144
+ relation: chain.length === 0 ? 'source-of' : chainIndex === 0 ? 'introduced' : 'patched-by',
145
+ basis: chain.length === 0 ? 'observed' : 'derived',
146
+ evidenceMode: row.evidenceKind
147
+ });
148
+ });
149
+ for (const diagnosticId of fact.relatedDiagnosticIds) {
150
+ const diagnostic = diagnostics.find((item) => item.id === diagnosticId);
151
+ if (diagnostic === undefined)
152
+ continue;
153
+ const diagnosticKey = `diagnostic:${diagnosticId}`;
154
+ addNode({ id: diagnosticKey, entity: 'diagnostic', label: diagnostic.title, diagnosticId, evidenceMode: diagnostic.evidence[0]?.evidenceKind ?? row.evidenceKind });
155
+ addEdge({ from: rowKey, to: diagnosticKey, relation: 'diagnosed-by', basis: 'derived', evidenceMode: diagnostic.evidence[0]?.evidenceKind ?? row.evidenceKind });
156
+ }
157
+ }
158
+ rows.sort((left, right) => left.key.localeCompare(right.key));
159
+ return {
160
+ schemaVersion: 1,
161
+ evidenceMode: reportMode,
162
+ nodes: [...nodes.values()].sort((left, right) => left.id.localeCompare(right.id)),
163
+ edges: [...edgeMap.values()].sort((left, right) => `${left.from}:${left.relation}:${left.to}`.localeCompare(`${right.from}:${right.relation}:${right.to}`)),
164
+ rows,
165
+ unknownSurfaces
166
+ };
167
+ }
168
+ export function findCompositionRow(facts, id) {
169
+ return facts.rows.find((row) => row.id === id);
170
+ }
171
+ export function compositionBundleImpact(report, name) {
172
+ const facts = report.compositionFacts;
173
+ const rows = facts?.rows.filter((row) => row.packageName === name || row.provenance.some((item) => item.source === name)) ?? [];
174
+ const rowKeys = new Set(rows.map((row) => row.key));
175
+ const sourceLabels = new Set(rows.flatMap((row) => row.provenance.map((item) => item.source)));
176
+ const sourceIds = new Set(facts?.nodes.filter((node) => node.entity === 'source' && sourceLabels.has(node.label)).map((node) => node.id) ?? []);
177
+ const bundleId = `bundle:${encodeURIComponent(name)}`;
178
+ const edges = facts?.edges.filter((edge) => rowKeys.has(edge.to) || rowKeys.has(edge.from) || sourceIds.has(edge.from) || sourceIds.has(edge.to) || edge.from === bundleId || edge.to === bundleId) ?? [];
179
+ const diagnosticIds = new Set(rows.flatMap((row) => row.relatedDiagnosticIds));
180
+ const diagnostics = report.diagnostics.filter((diagnostic) => diagnosticIds.has(diagnostic.id) || diagnostic.evidence.some((item) => item.packageName === name));
181
+ return {
182
+ schemaVersion: 1,
183
+ bundle: name,
184
+ evidenceMode: facts?.evidenceMode ?? (report.evidenceMode === 'resolved' ? 'static' : report.evidenceMode),
185
+ rows,
186
+ edges,
187
+ diagnostics,
188
+ unknown: facts?.unknownSurfaces ?? [...unknownSurfaces]
189
+ };
190
+ }
191
+ /** Normalize paths in diagnostic evidence before adding them to a report. */
192
+ export function redactDiagnosticPaths(diagnostics, model) {
193
+ const knownPaths = [model.profileDir, ...(model.installedPackages ?? []).flatMap((fact) => [fact.packageJsonSource, ...(fact.bundlePatch === undefined ? [] : [fact.bundlePatch])])]
194
+ .filter((value) => value.length > 0);
195
+ return diagnostics.map((diagnostic) => ({
196
+ ...diagnostic,
197
+ evidence: diagnostic.evidence.map((item) => {
198
+ let detail = item.detail;
199
+ for (const path of knownPaths) {
200
+ const normalized = path.replaceAll('\\', '/');
201
+ detail = detail.replaceAll(path, normalizeCompositionSource(path, model.profileDir)).replaceAll(normalized, normalizeCompositionSource(path, model.profileDir));
202
+ }
203
+ return { ...item, source: normalizeCompositionSource(item.source, model.profileDir), detail };
204
+ })
205
+ }));
206
+ }
1
207
  export function rowEvidence(row) {
2
208
  return {
3
209
  source: row.source,
@@ -83,6 +83,7 @@ export function parseDshDump(stdout, stderr = '') {
83
83
  const provenance = provenanceParts(typeof object.source === 'string' ? object.source : provenanceHints[index] ?? legacyProvenanceHints[index]);
84
84
  const source = typeof object.source === 'string' ? object.source : provenance.source ?? '<DSH dump>';
85
85
  const config = object.config;
86
+ const explicitReplacement = object.replacement === true || object.overridden === true;
86
87
  const row = {
87
88
  ...(typeof object.id === 'string' ? { id: object.id } : {}),
88
89
  ...(typeof object.name === 'string' ? { name: object.name } : typeof object.plugin === 'string' ? { name: object.plugin } : {}),
@@ -92,7 +93,9 @@ export function parseDshDump(stdout, stderr = '') {
92
93
  ...(typeof object.layer === 'string' ? { layer: object.layer } : {}),
93
94
  ...(typeof object.layerOrder === 'number' ? { layerOrder: object.layerOrder } : {}),
94
95
  ...(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
+ ...(explicitReplacement || (provenance.provenance !== undefined && provenance.provenance.length > 1) || (typeof object.id === 'string' && seenIds.has(object.id) && seenIds.get(object.id) !== source)
97
+ ? { replacement: true, replacementBasis: explicitReplacement ? 'observed' : 'derived' }
98
+ : {})
96
99
  };
97
100
  if (row.id !== undefined)
98
101
  seenIds.set(row.id, row.source);
@@ -1,4 +1,5 @@
1
1
  import { satisfies as semverSatisfies } from 'semver';
2
+ import { buildCompositionFacts, redactDiagnosticPaths } from './composition-provenance.js';
2
3
  function evidence(source, subject, detail, evidenceKind, packageName, version) {
3
4
  return { source, subject, detail, evidenceKind, ...(packageName === undefined ? {} : { packageName }), ...(version === undefined ? {} : { version }) };
4
5
  }
@@ -157,6 +158,18 @@ export function analyseComposition(model) {
157
158
  }
158
159
  diagnostics.sort((left, right) => left.id.localeCompare(right.id) || left.title.localeCompare(right.title) || left.evidence[0]?.source.localeCompare(right.evidence[0]?.source ?? '') || 0);
159
160
  const evidenceMode = runtimeObserved ? 'runtime-observed' : requestedMode === 'runtime-observed' ? 'composed' : requestedMode;
160
- const unverifiedFindings = diagnostics.filter((item) => item.id === 'runtime-composition-unavailable' || item.evidence.some((entry) => entry.evidenceKind === 'static')).map((item) => item.id);
161
- return { schemaVersion: 1, evidenceSchemaVersion: 2, generatedAt: new Date().toISOString(), profileDir: model.profileDir, evidenceMode, runtimeObserved, ...(model.metadataCoverage === undefined ? {} : { metadataCoverage: model.metadataCoverage }), unverifiedFindings, diagnostics };
161
+ const redactedDiagnostics = redactDiagnosticPaths(diagnostics, model);
162
+ const unverifiedFindings = redactedDiagnostics.filter((item) => item.id === 'runtime-composition-unavailable' || item.evidence.some((entry) => entry.evidenceKind === 'static')).map((item) => item.id);
163
+ const reportBase = {
164
+ schemaVersion: 1,
165
+ evidenceSchemaVersion: 2,
166
+ generatedAt: new Date().toISOString(),
167
+ profileDir: '<PROFILE>',
168
+ evidenceMode,
169
+ runtimeObserved,
170
+ ...(model.metadataCoverage === undefined ? {} : { metadataCoverage: model.metadataCoverage }),
171
+ unverifiedFindings,
172
+ diagnostics: redactedDiagnostics
173
+ };
174
+ return { ...reportBase, compositionFacts: buildCompositionFacts({ ...model, evidenceMode }, redactedDiagnostics) };
162
175
  }
@@ -12,6 +12,44 @@ export const analysisReportJsonSchema = {
12
12
  evidenceMode: { enum: ['static', 'composed', 'runtime-observed', 'mixed'] },
13
13
  runtimeObserved: { type: 'boolean' },
14
14
  unverifiedFindings: { type: 'array', items: { type: 'string' } },
15
+ compositionFacts: {
16
+ type: 'object',
17
+ required: ['schemaVersion', 'evidenceMode', 'nodes', 'edges', 'rows', 'unknownSurfaces'],
18
+ properties: {
19
+ schemaVersion: { const: 1 },
20
+ evidenceMode: { enum: ['static', 'composed', 'runtime-observed', 'mixed'] },
21
+ nodes: { type: 'array', items: {
22
+ type: 'object', required: ['id', 'entity', 'label'],
23
+ properties: {
24
+ id: { type: 'string' }, entity: { enum: ['bundle', 'source', 'layer', 'row', 'diagnostic'] }, label: { type: 'string' },
25
+ source: { type: 'string' }, layer: { type: 'string' }, layerOrder: { type: 'number' },
26
+ packageName: { type: 'string' }, packageVersion: { type: 'string' },
27
+ evidenceMode: { enum: ['static', 'composed', 'runtime-observed'] }, rowKey: { type: 'string' }, diagnosticId: { type: 'string' }
28
+ }, additionalProperties: false
29
+ } },
30
+ edges: { type: 'array', items: {
31
+ type: 'object', required: ['from', 'to', 'relation', 'basis', 'evidenceMode'],
32
+ properties: {
33
+ from: { type: 'string' }, to: { type: 'string' },
34
+ relation: { enum: ['introduced', 'patched-by', 'source-of', 'contains', 'diagnosed-by', 'package-source'] },
35
+ basis: { enum: ['observed', 'derived'] }, evidenceMode: { enum: ['static', 'composed', 'runtime-observed'] }
36
+ }, additionalProperties: false
37
+ } },
38
+ rows: { type: 'array', items: {
39
+ type: 'object', required: ['entity', 'key', 'source', 'sourceBasis', 'provenance', 'replacement', 'configKeys', 'configKeysBasis', 'evidenceMode', 'relatedDiagnosticIds', 'unknown'],
40
+ properties: {
41
+ entity: { const: 'row' }, key: { type: 'string' }, id: { type: 'string' }, name: { type: 'string' },
42
+ source: { type: 'string' }, sourceBasis: { const: 'observed' }, layer: { type: 'string' }, layerOrder: { type: 'number' },
43
+ provenance: { type: 'array', items: { type: 'object', required: ['source', 'sourceBasis', 'relation', 'basis'], properties: { source: { type: 'string' }, sourceBasis: { const: 'observed' }, relation: { enum: ['introduced', 'patched-by'] }, basis: { enum: ['observed', 'derived'] } }, additionalProperties: false } },
44
+ replacement: { type: 'object', required: ['state', 'basis'], properties: { state: { enum: ['yes', 'no', 'unknown'] }, basis: { enum: ['observed', 'derived', 'unknown'] } }, additionalProperties: false },
45
+ configKeys: { type: 'array', items: { type: 'string' } }, configKeysBasis: { enum: ['observed', 'derived'] }, evidenceMode: { enum: ['static', 'composed', 'runtime-observed'] },
46
+ packageName: { type: 'string' }, packageVersion: { type: 'string' }, relatedDiagnosticIds: { type: 'array', items: { type: 'string' } },
47
+ removedConfigKeys: { type: 'array', items: { type: 'string' } }, unknown: { type: 'array', items: { type: 'string' } }
48
+ }, additionalProperties: false
49
+ } },
50
+ unknownSurfaces: { type: 'array', items: { type: 'string' } }
51
+ }, additionalProperties: false
52
+ },
15
53
  diagnostics: {
16
54
  type: 'array',
17
55
  items: {