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/CHANGELOG.md +47 -28
- package/README.md +179 -74
- package/README.zh.md +179 -74
- package/dist/cli/main.js +199 -5
- package/dist/client/report-view.js +186 -1
- package/dist/client.js +211 -2
- package/dist/core/bundle-inventory.js +9 -6
- package/dist/core/composition-provenance.js +211 -0
- package/dist/core/dsh-dump-parser.js +5 -2
- package/dist/core/failure-explainer.js +205 -0
- package/dist/core/failure-profile.js +110 -0
- package/dist/core/package-provenance.js +4 -0
- package/dist/core/preflight/npm-artifact.js +18 -2
- package/dist/core/rules.js +24 -4
- package/dist/core/snapshot.js +1 -1
- package/dist/reports/failure.js +23 -0
- package/dist/reports/markdown.js +3 -1
- package/dist/reports/schema.js +67 -0
- package/package.json +5 -3
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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');
|