dsh-composition-doctor 0.2.2 → 0.4.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/cli/main.js CHANGED
@@ -1,18 +1,140 @@
1
+ #!/usr/bin/env node
1
2
  import { fileURLToPath } from 'node:url';
2
3
  import { readFileSync, realpathSync } from 'node:fs';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
4
5
  import { dirname, resolve } from 'node:path';
5
6
  import { resolveComposition } from '../core/composition-adapter.js';
6
7
  import { diffSnapshots, renderSnapshotDiffMarkdown } from '../core/diff.js';
7
8
  import { readProfile } from '../core/profile-reader.js';
8
9
  import { createSnapshot, isSnapshotV1, isSnapshotV2 } from '../core/snapshot.js';
9
10
  import { analyseComposition } from '../core/rules.js';
11
+ import { compositionBundleImpact, findCompositionRow } from '../core/composition-provenance.js';
10
12
  import { renderJson } from '../reports/json.js';
11
13
  import { renderMarkdown } from '../reports/markdown.js';
12
14
  import { resolveReportDirectory } from '../reports/location.js';
13
15
  import { runPreflight } from '../core/preflight.js';
14
- const usage = 'Usage: dsh-doctor <scan | snapshot | diff | preflight>';
15
- const commands = new Set(['scan', 'snapshot', 'diff', 'preflight']);
16
+ import { explainFailures, maxFailureLogBytes } from '../core/failure-explainer.js';
17
+ import { readFailureComposition } from '../core/failure-profile.js';
18
+ import { renderFailure } from '../reports/failure.js';
19
+ const usage = 'Usage: dsh-doctor <diagnose | check | scan | snapshot | diff | preflight | why | impact>';
20
+ const commands = new Set(['diagnose', 'check', 'scan', 'snapshot', 'diff', 'preflight', 'why', 'impact']);
21
+ async function stdinLog() {
22
+ if (process.stdin.isTTY)
23
+ return undefined;
24
+ const chunks = [];
25
+ let size = 0;
26
+ for await (const chunk of process.stdin) {
27
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
28
+ size += buffer.length;
29
+ if (size > maxFailureLogBytes)
30
+ throw new Error('Log exceeds the 1 MiB limit; provide a minimized error log.');
31
+ chunks.push(buffer);
32
+ }
33
+ return chunks.length === 0 ? undefined : Buffer.concat(chunks).toString('utf8');
34
+ }
35
+ async function fileLog(filename) {
36
+ const path = await realpath(resolve(filename));
37
+ if (!/\.(?:log|txt|out)$/i.test(path) || /(?:^|[\\/])(?:\.env[^\\/]*|[^\\/]*(?:credential|token|secret|password)[^\\/]*|sessions?|chats?|workspace)(?:[\\/]|$)/i.test(path)) {
38
+ throw new Error('--log accepts explicit .log/.txt/.out error logs; secret, session and workspace paths are forbidden.');
39
+ }
40
+ const metadata = await lstat(path);
41
+ if (!metadata.isFile() || metadata.size > maxFailureLogBytes)
42
+ throw new Error('Log must be a regular file of at most 1 MiB.');
43
+ const text = await readFile(path, 'utf8');
44
+ if (Buffer.byteLength(text) > maxFailureLogBytes)
45
+ throw new Error('Log exceeds the 1 MiB limit.');
46
+ return text;
47
+ }
48
+ async function diagnose(argv, io, check = false) {
49
+ if (argv.includes('--help')) {
50
+ io.write('Usage: dsh-doctor diagnose --profile <dir> [--log <file> | --log -] [--format human|json|markdown|both] [--output <dir>] [--report-dir <dir> | --publish] [--dsh-version <version>] [--composed]\nPiped stdin is detected automatically. Default: read-only static metadata, no DSH execution. check is the same bounded entry point; --profile is required.');
51
+ return 0;
52
+ }
53
+ const valued = new Set(['--profile', '--log', '--format', '--output', '--report-dir', '--dsh-version']);
54
+ const flags = new Set(['--publish', '--composed']);
55
+ const seen = new Set();
56
+ for (let index = 0; index < argv.length; index += 1) {
57
+ const value = argv[index];
58
+ if ((!valued.has(value) && !flags.has(value)) || seen.has(value))
59
+ return invalid(io, 'diagnose has an unknown or repeated option');
60
+ seen.add(value);
61
+ if (valued.has(value)) {
62
+ if (argv[index + 1] === undefined || argv[index + 1].startsWith('--'))
63
+ return invalid(io, `${value} requires a value`);
64
+ index += 1;
65
+ }
66
+ }
67
+ const profile = option(argv, '--profile');
68
+ if (profile === undefined)
69
+ return invalid(io, `${check ? 'check' : 'diagnose'} requires --profile <dir>; no profile is guessed`);
70
+ const format = option(argv, '--format') ?? 'human';
71
+ if (!['human', 'json', 'markdown', 'both'].includes(format))
72
+ return invalid(io, '--format must be human, json, markdown, or both');
73
+ const dshVersion = option(argv, '--dsh-version');
74
+ if (dshVersion !== undefined && !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?(?:\+[a-z0-9.-]+)?$/i.test(dshVersion))
75
+ return invalid(io, '--dsh-version requires an exact semantic version');
76
+ let log;
77
+ const logPath = option(argv, '--log');
78
+ try {
79
+ log = logPath !== undefined && logPath !== '-' ? await fileLog(logPath) : await io.readStdin?.();
80
+ }
81
+ catch {
82
+ io.write('Could not read permitted log input. Use a minimized .log/.txt/.out file or stdin, at most 1 MiB; secret/session/workspace paths are forbidden.');
83
+ return 1;
84
+ }
85
+ if (logPath === '-' && log === undefined)
86
+ return invalid(io, '--log - requires piped input');
87
+ if (log !== undefined && Buffer.byteLength(log) > maxFailureLogBytes)
88
+ return invalid(io, 'log exceeds 1 MiB');
89
+ const input = await readProfile({ profileDir: resolve(profile) });
90
+ let model = await readFailureComposition(input, argv.includes('--composed'));
91
+ if (dshVersion !== undefined)
92
+ model = { ...model, runtime: { ...model.runtime, dsh: dshVersion } };
93
+ const report = analyseComposition(model);
94
+ const result = explainFailures({ report, log });
95
+ report.failureExplanation = result;
96
+ const output = option(argv, '--output');
97
+ const reportDir = option(argv, '--report-dir');
98
+ const destinations = [...(output === undefined ? [] : [resolve(output)]),
99
+ ...(argv.includes('--publish') || reportDir !== undefined ? [resolveReportDirectory(reportDir)] : [])];
100
+ // Prevent report output from mutating the selected profile, including symlink parents.
101
+ for (const directory of destinations) {
102
+ let parent = directory;
103
+ let canonical;
104
+ for (;;) {
105
+ try {
106
+ canonical = await realpath(parent);
107
+ break;
108
+ }
109
+ catch {
110
+ const next = dirname(parent);
111
+ if (next === parent)
112
+ break;
113
+ parent = next;
114
+ }
115
+ }
116
+ const root = input.profileDir.toLowerCase().replaceAll('\\', '/');
117
+ const normalized = (canonical ?? directory).toLowerCase().replaceAll('\\', '/');
118
+ if (normalized === root || normalized.startsWith(`${root}/`))
119
+ return invalid(io, 'diagnose output/report directory must be outside the selected profile');
120
+ }
121
+ for (const directory of new Set(destinations)) {
122
+ await mkdir(directory, { recursive: true });
123
+ for (const filename of ['report.json', 'report.md']) {
124
+ try {
125
+ const metadata = await lstat(resolve(directory, filename));
126
+ if (metadata.isSymbolicLink() || metadata.nlink > 1)
127
+ return invalid(io, 'report destination must not be a symbolic or hard link');
128
+ }
129
+ catch { /* new file */ }
130
+ }
131
+ await writeFile(resolve(directory, 'report.json'), renderJson(report), 'utf8');
132
+ await writeFile(resolve(directory, 'report.md'), renderFailure(result, true), 'utf8');
133
+ }
134
+ io.write(format === 'json' ? `${JSON.stringify(result, null, 2)}\n` : format === 'both'
135
+ ? `${JSON.stringify(result, null, 2)}\n${renderFailure(result, true)}` : renderFailure(result, format === 'markdown'));
136
+ return 0;
137
+ }
16
138
  function packageVersion() {
17
139
  try {
18
140
  const manifest = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '../../package.json'), 'utf8'));
@@ -146,7 +268,69 @@ async function preflight(argv, io) {
146
268
  }
147
269
  return result.outcome === 'fail' ? 1 : 0;
148
270
  }
149
- export async function runCli(argv, io = { write: (line) => console.log(line) }) {
271
+ async function why(argv, io) {
272
+ if (argv[0] !== 'row' || argv[1] === undefined || argv[1].startsWith('--'))
273
+ return invalid(io, 'why requires: why row <id> --profile <dir>');
274
+ const profile = option(argv, '--profile');
275
+ if (profile === undefined)
276
+ return invalid(io, 'why row requires --profile');
277
+ const model = await resolveComposition(await readProfile({ profileDir: resolve(profile) }));
278
+ const report = analyseComposition(model);
279
+ const row = findCompositionRow(report.compositionFacts, argv[1]);
280
+ if (row === undefined) {
281
+ io.write(`${JSON.stringify({ schemaVersion: 1, query: { entity: 'row', id: argv[1] }, outcome: 'not-found', evidenceMode: report.evidenceMode, unknown: ['No row with this id was present in the selected profile evidence.'] }, null, 2)}\n`);
282
+ return 0;
283
+ }
284
+ const relatedDiagnostics = report.diagnostics.filter((item) => row.relatedDiagnosticIds.includes(item.id));
285
+ const unknown = [...row.unknown];
286
+ if (row.replacement.state === 'yes' && row.removedConfigKeys === undefined)
287
+ unknown.push('removed config keys are unknown; prior layer config was not fully observed');
288
+ io.write(`${JSON.stringify({
289
+ schemaVersion: 1,
290
+ query: { entity: 'row', id: argv[1] },
291
+ outcome: 'found',
292
+ evidenceMode: report.evidenceMode,
293
+ row: {
294
+ id: row.id,
295
+ ...(row.name === undefined ? {} : { name: row.name }),
296
+ effectiveObservedStructure: { configKeys: row.configKeys, values: 'not included; values may contain sensitive or runtime-dependent data' },
297
+ source: row.source,
298
+ sourceBasis: row.sourceBasis,
299
+ ...(row.layer === undefined ? {} : { layer: row.layer }),
300
+ ...(row.layerOrder === undefined ? {} : { layerOrder: row.layerOrder }),
301
+ provenance: row.provenance,
302
+ replacement: row.replacement,
303
+ configKeysBasis: row.configKeysBasis,
304
+ ...(row.removedConfigKeys === undefined ? {} : { removedConfigKeys: row.removedConfigKeys }),
305
+ ...(row.packageName === undefined ? {} : { package: { name: row.packageName, ...(row.packageVersion === undefined ? {} : { version: row.packageVersion }) } }),
306
+ evidenceMode: row.evidenceMode
307
+ },
308
+ relatedDiagnostics,
309
+ unknown: [...new Set(unknown)].sort()
310
+ }, null, 2)}\n`);
311
+ return 0;
312
+ }
313
+ async function impact(argv, io) {
314
+ if (argv[0] !== 'bundle' || argv[1] === undefined || argv[1].startsWith('--'))
315
+ return invalid(io, 'impact requires: impact bundle <name> --profile <dir>');
316
+ const profile = option(argv, '--profile');
317
+ if (profile === undefined)
318
+ return invalid(io, 'impact bundle requires --profile');
319
+ const model = await resolveComposition(await readProfile({ profileDir: resolve(profile) }));
320
+ const report = analyseComposition(model);
321
+ const result = compositionBundleImpact(report, argv[1]);
322
+ const known = report.compositionFacts?.nodes.some((node) => node.entity === 'bundle' && node.label === argv[1]) === true;
323
+ io.write(`${JSON.stringify({
324
+ ...result,
325
+ outcome: known ? (result.rows.length > 0 ? 'observed-contributions' : 'no-observable-rows') : 'bundle-not-observed',
326
+ observed: known,
327
+ patchContributions: result.rows.filter((row) => row.provenance.some((item) => item.source === argv[1] && item.relation === 'patched-by')),
328
+ overrides: result.rows.filter((row) => row.replacement.state === 'yes' && row.provenance.some((item) => item.source === argv[1] && item.relation === 'patched-by')),
329
+ unknown: [...new Set(result.unknown)]
330
+ }, null, 2)}\n`);
331
+ return 0;
332
+ }
333
+ export async function runCli(argv, io = { write: (line) => console.log(line), readStdin: stdinLog }) {
150
334
  const command = argv[0];
151
335
  if (argv.length === 0 || command === '--help') {
152
336
  io.write(usage);
@@ -161,15 +345,25 @@ export async function runCli(argv, io = { write: (line) => console.log(line) })
161
345
  return 2;
162
346
  }
163
347
  try {
348
+ if (command === 'diagnose' || command === 'check')
349
+ return await diagnose(argv.slice(1), io, command === 'check');
164
350
  if (command === 'scan')
165
351
  return await scan(argv.slice(1), io);
166
352
  if (command === 'snapshot')
167
353
  return await snapshot(argv.slice(1), io);
168
354
  if (command === 'diff')
169
355
  return await diff(argv.slice(1), io);
170
- return await preflight(argv.slice(1), io);
356
+ if (command === 'preflight')
357
+ return await preflight(argv.slice(1), io);
358
+ if (command === 'why')
359
+ return await why(argv.slice(1), io);
360
+ return await impact(argv.slice(1), io);
171
361
  }
172
362
  catch (error) {
363
+ if (command === 'diagnose' || command === 'check') {
364
+ io.write('Could not inspect selected profile metadata. Check --profile directory, metadata syntax and file access.');
365
+ return 1;
366
+ }
173
367
  io.write(error instanceof Error ? error.message : String(error));
174
368
  return 1;
175
369
  }
@@ -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,141 @@ function renderConflictGraph(document, graph) {
59
107
  section.append(list);
60
108
  return section;
61
109
  }
110
+ export function createFailureExplanation(document, report) {
111
+ const section = document.createElement('section');
112
+ section.dataset.failureExplanation = 'true';
113
+ const heading = document.createElement('h3');
114
+ heading.textContent = 'Failure Explanation';
115
+ section.append(heading);
116
+ const result = report.failureExplanation;
117
+ if (result === undefined) {
118
+ const message = document.createElement('p');
119
+ message.textContent = 'No failure explanation in this report. Run dsh-doctor diagnose --profile <dir> --report-dir <doctor-reports> to create one.';
120
+ section.append(message);
121
+ return section;
122
+ }
123
+ const status = document.createElement('p');
124
+ status.textContent = `Outcome: ${result.outcome}; runtime: ${result.runtime}. Log signatures are reported claims, not composition evidence.`;
125
+ section.append(status);
126
+ for (const explanation of result.explanations) {
127
+ const details = document.createElement('details');
128
+ const summary = document.createElement('summary');
129
+ summary.textContent = `${explanation.detectedFailure} (${explanation.origin}; ${explanation.evidenceLevel})`;
130
+ const cause = document.createElement('p');
131
+ cause.textContent = explanation.observableCause;
132
+ const paths = document.createElement('pre');
133
+ paths.textContent = explanation.provenancePaths.map((path) => `${path.packageName ?? 'profile'} → ${path.source} → ${path.layer ?? 'unknown layer'} → ${path.rowId ?? 'unknown row'}\n${path.provenance.map((step) => `${step.relation}: ${step.source} (${step.basis})`).join(' → ')}`).join('\n\n');
134
+ const unknown = document.createElement('p');
135
+ unknown.textContent = `Unknown: ${explanation.unknowns.join(' ')}`;
136
+ const diagnostics = document.createElement('p');
137
+ diagnostics.textContent = `Related diagnostics: ${explanation.relatedDiagnostics.map((item) => item.id).join(', ') || 'none observed'}`;
138
+ const remediation = document.createElement('p');
139
+ remediation.textContent = `Manual next step: ${explanation.manualRemediation}`;
140
+ details.append(summary, cause, paths, diagnostics, unknown, remediation);
141
+ section.append(details);
142
+ }
143
+ const next = document.createElement('p');
144
+ next.textContent = `${result.nextStep} Doctor did not modify your profile.`;
145
+ section.append(next);
146
+ return section;
147
+ }
148
+ export function createCompositionExplorer(document, report) {
149
+ const section = document.createElement('section');
150
+ section.setAttribute('aria-labelledby', 'dsh-composition-doctor-explorer');
151
+ const heading = document.createElement('h3');
152
+ heading.id = 'dsh-composition-doctor-explorer';
153
+ heading.textContent = 'Composition Explorer';
154
+ section.append(heading);
155
+ const facts = report.compositionFacts;
156
+ const graph = facts === undefined ? { nodes: [], edges: [] } : { nodes: facts.nodes, edges: facts.edges };
157
+ if (graph.nodes.length === 0) {
158
+ const message = document.createElement('p');
159
+ message.textContent = 'Composition provenance is unavailable in this legacy report.';
160
+ section.append(message);
161
+ }
162
+ else {
163
+ const list = document.createElement('ul');
164
+ for (const node of graph.nodes.filter((value) => value.entity !== 'diagnostic')) {
165
+ const item = document.createElement('li');
166
+ const select = button(document, `${node.entity}: ${node.label}`, () => {
167
+ const detail = compositionNodeDetail(report, node.id);
168
+ if (detail === undefined)
169
+ return;
170
+ const panel = section.querySelector('[data-composition-detail]');
171
+ if (panel === null)
172
+ return;
173
+ panel.replaceChildren();
174
+ const title = document.createElement('h4');
175
+ title.textContent = detail.title;
176
+ panel.append(title);
177
+ const fields = document.createElement('dl');
178
+ for (const field of detail.fields) {
179
+ const term = document.createElement('dt');
180
+ term.textContent = field.label;
181
+ const value = document.createElement('dd');
182
+ value.textContent = field.value;
183
+ fields.append(term, value);
184
+ }
185
+ panel.append(fields);
186
+ const diagnostics = document.createElement('ul');
187
+ for (const diagnostic of detail.diagnostics) {
188
+ const entry = document.createElement('li');
189
+ entry.textContent = `[${diagnostic.severity}] ${diagnostic.title}: ${diagnostic.explanation}`;
190
+ diagnostics.append(entry);
191
+ }
192
+ if (detail.diagnostics.length > 0)
193
+ panel.append(diagnostics);
194
+ const unknown = document.createElement('ul');
195
+ for (const value of detail.unknown) {
196
+ const entry = document.createElement('li');
197
+ entry.textContent = value;
198
+ unknown.append(entry);
199
+ }
200
+ if (detail.unknown.length > 0) {
201
+ const unknownTitle = document.createElement('h4');
202
+ unknownTitle.textContent = 'Unknown / not modelled';
203
+ panel.append(unknownTitle, unknown);
204
+ }
205
+ });
206
+ select.dataset.compositionNode = node.id;
207
+ if (report.failureExplanation?.explanations.some((item) => item.provenancePaths.some((path) => path.rowKey === node.rowKey && node.rowKey !== undefined || path.packageName === node.packageName && node.packageName !== undefined))) {
208
+ select.dataset.failureRelated = 'true';
209
+ select.textContent = `${node.entity}: ${node.label} [failure evidence]`;
210
+ }
211
+ item.append(select);
212
+ const outgoing = graph.edges.filter((edge) => edge.from === node.id);
213
+ if (outgoing.length > 0) {
214
+ const relationships = document.createElement('ul');
215
+ for (const edge of outgoing) {
216
+ const target = graph.nodes.find((candidate) => candidate.id === edge.to);
217
+ if (target === undefined || target.entity === 'diagnostic')
218
+ continue;
219
+ const relationship = document.createElement('li');
220
+ relationship.textContent = `${edge.relation} → ${target.label} (${edge.basis}; ${edge.evidenceMode})`;
221
+ relationships.append(relationship);
222
+ }
223
+ item.append(relationships);
224
+ }
225
+ list.append(item);
226
+ }
227
+ section.append(list);
228
+ const detail = document.createElement('section');
229
+ detail.dataset.compositionDetail = 'true';
230
+ detail.setAttribute('aria-live', 'polite');
231
+ detail.textContent = 'Select a bundle, source, layer, or row to inspect its evidence.';
232
+ section.append(detail);
233
+ const unknownList = document.createElement('ul');
234
+ for (const value of facts?.unknownSurfaces ?? []) {
235
+ const entry = document.createElement('li');
236
+ entry.textContent = value;
237
+ unknownList.append(entry);
238
+ }
239
+ const unknownTitle = document.createElement('h4');
240
+ unknownTitle.textContent = 'Other surfaces';
241
+ section.append(unknownTitle, unknownList);
242
+ }
243
+ return section;
244
+ }
62
245
  function isReport(value) {
63
246
  if (value === null || typeof value !== 'object')
64
247
  return false;
@@ -145,6 +328,8 @@ export function createReportView(document = globalThis.document, translate = (ke
145
328
  status.textContent = `Generated: ${report.generatedAt}. Profile: ${report.profileDir}. Diagnostics: ${model.counts.error} error, ${model.counts.warning} warning, ${model.counts.info} info.`;
146
329
  status.append(document.createTextNode(' '), badge);
147
330
  root.append(renderConflictGraph(document, model.graph));
331
+ root.append(createFailureExplanation(document, report));
332
+ root.append(createCompositionExplorer(document, report));
148
333
  const list = document.createElement('ul');
149
334
  for (const diagnostic of report.diagnostics) {
150
335
  const item = document.createElement('li');