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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
export const maxFailureLogBytes = 1024 * 1024;
|
|
2
|
+
const titles = {
|
|
3
|
+
'duplicate-loader-entry': 'Duplicate loader entry id', 'bundle-unavailable': 'Profile bundle cannot be resolved',
|
|
4
|
+
'bundle-patch-unavailable': 'Bundle patch is unavailable', 'patch-target-missing': 'Patch entry not found',
|
|
5
|
+
'peer-incompatible': 'Plugin peer/version incompatibility', 'config-replacement': 'Whole-config replacement / row override'
|
|
6
|
+
};
|
|
7
|
+
const ids = {
|
|
8
|
+
'duplicate-loader-entry': ['duplicate-loader-declarations', 'duplicate-row-in-same-layer', 'intentional-row-override'],
|
|
9
|
+
'bundle-unavailable': ['bundle-not-installed'],
|
|
10
|
+
'bundle-patch-unavailable': ['bundle-patch-missing', 'bundle-patch-unavailable', 'bundle-patch-invalid', 'bundle-patch-unsafe'],
|
|
11
|
+
'patch-target-missing': ['unmatched-patch-target'], 'peer-incompatible': ['peer-version-mismatch', 'peer-version-facts-unavailable'],
|
|
12
|
+
'config-replacement': ['row-config-replacement-risk', 'intentional-row-override']
|
|
13
|
+
};
|
|
14
|
+
/** No raw log lines, stack traces, URLs, paths or values are persisted. */
|
|
15
|
+
export function safeFailureEntity(value) {
|
|
16
|
+
return /^(?:@[a-z0-9_.-]+\/)?[a-z0-9_][a-z0-9_.:-]{0,159}$/i.test(value) &&
|
|
17
|
+
!/(?:^(?:sk-|gh[pousr]_|eyJ)|token|password|credential|secret|authorization)/i.test(value) ? value : undefined;
|
|
18
|
+
}
|
|
19
|
+
export function matchFailureSignatures(log) {
|
|
20
|
+
const signatures = new Map();
|
|
21
|
+
const entity = '(?:@[a-z0-9_.-]+/)?[a-z0-9_][a-z0-9_.:-]{0,159}';
|
|
22
|
+
const add = (pattern, value) => {
|
|
23
|
+
const candidate = value === undefined ? undefined : safeFailureEntity(value);
|
|
24
|
+
if (value !== undefined && candidate === undefined)
|
|
25
|
+
return;
|
|
26
|
+
const signature = { pattern, ...(candidate === undefined ? {} : { entity: candidate }) };
|
|
27
|
+
if (signatures.size < 32)
|
|
28
|
+
signatures.set(`${pattern}:${candidate ?? ''}`, signature);
|
|
29
|
+
};
|
|
30
|
+
// Bound the work before matching; strip terminal control sequences, never render them.
|
|
31
|
+
const text = log.slice(0, maxFailureLogBytes).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
|
|
32
|
+
for (const line of text.split(/\r?\n/)) {
|
|
33
|
+
if (line.length > 4096)
|
|
34
|
+
continue;
|
|
35
|
+
let match = new RegExp(`duplicate loader entry id:\\s*["']?(${entity})(?=["'\\s]|$)`, 'i').exec(line);
|
|
36
|
+
if (match)
|
|
37
|
+
add('duplicate-loader-entry', match[1]);
|
|
38
|
+
match = new RegExp(`cannot resolve profile bundle ["'](${entity})["']`, 'i').exec(line);
|
|
39
|
+
if (match)
|
|
40
|
+
add('bundle-unavailable', match[1]);
|
|
41
|
+
match = new RegExp(`patch:\\s*entry ["'](${entity})["'] not found`, 'i').exec(line);
|
|
42
|
+
if (match)
|
|
43
|
+
add('patch-target-missing', match[1]);
|
|
44
|
+
if (/failed to read overlay\b/i.test(line)) {
|
|
45
|
+
match = new RegExp(`node_modules[\\\\/](${entity.replace('/', '[\\\\/]')})[\\\\/]cordis\\.patch\\.ya?ml(?=[\\s:]|$)`, 'i').exec(line);
|
|
46
|
+
add('bundle-patch-unavailable', match?.[1]?.replaceAll('\\', '/'));
|
|
47
|
+
}
|
|
48
|
+
match = new RegExp(`incompatible-version:\\s*(${entity})(?=[\\s,]|$)`, 'i').exec(line);
|
|
49
|
+
if (match)
|
|
50
|
+
add('peer-incompatible', match[1]);
|
|
51
|
+
match = new RegExp(`Plugin (${entity})@[0-9][a-z0-9.+-]* is incompatible with dsh [0-9][a-z0-9.+-]*: peerDependencies`, 'i').exec(line);
|
|
52
|
+
if (match)
|
|
53
|
+
add('peer-incompatible', match[1]);
|
|
54
|
+
else if (/\bERR_PNPM_PEER_DEP_ISSUES\b|\bplugin compatibility failure\b/.test(line))
|
|
55
|
+
add('peer-incompatible');
|
|
56
|
+
}
|
|
57
|
+
return [...signatures.values()];
|
|
58
|
+
}
|
|
59
|
+
function path(row) {
|
|
60
|
+
return { source: row.source, ...(row.layer === undefined ? {} : { layer: row.layer }),
|
|
61
|
+
...(row.packageName === undefined ? {} : { packageName: row.packageName }),
|
|
62
|
+
...(row.id === undefined ? {} : { rowId: row.id }), rowKey: row.key, provenance: row.provenance, evidence: row.evidenceMode };
|
|
63
|
+
}
|
|
64
|
+
function distinctRows(rows) {
|
|
65
|
+
// Preserve same-layer duplicates with their own row keys.
|
|
66
|
+
return [...new Map(rows.map((row) => [row.key, row])).values()];
|
|
67
|
+
}
|
|
68
|
+
function diagnosticEntities(diagnostic) {
|
|
69
|
+
return [...new Set(diagnostic.evidence.flatMap((item) => {
|
|
70
|
+
return [item.packageName, item.subject].filter((name) => name !== undefined && safeFailureEntity(name) !== undefined);
|
|
71
|
+
}))];
|
|
72
|
+
}
|
|
73
|
+
function explain(signature, report, reported) {
|
|
74
|
+
const { pattern, entity } = signature;
|
|
75
|
+
const allRows = report.compositionFacts?.rows ?? [];
|
|
76
|
+
const rowPattern = pattern === 'duplicate-loader-entry' || pattern === 'patch-target-missing' || pattern === 'config-replacement';
|
|
77
|
+
const rows = allRows.filter((row) => rowPattern ? row.id === entity :
|
|
78
|
+
row.packageName === entity || row.provenance.some((step) => step.source === entity));
|
|
79
|
+
const diagnostics = report.diagnostics.filter((item) => ids[pattern].includes(item.id) &&
|
|
80
|
+
(entity === undefined || diagnosticEntities(item).includes(entity)));
|
|
81
|
+
let paths = distinctRows(rows).map(path);
|
|
82
|
+
let cause = 'unknown: the reported signature has no supporting condition in the selected profile evidence.';
|
|
83
|
+
let observed = false;
|
|
84
|
+
let evidenceLevel = 'unknown';
|
|
85
|
+
const unknowns = ['Runtime failure and plugin behavior: not observed / not-run.'];
|
|
86
|
+
let remediation = 'Check the selected profile directory and capture the first DSH error. Compare it with the observed rows; do not remove a plugin based on a log string alone.';
|
|
87
|
+
if (pattern === 'duplicate-loader-entry') {
|
|
88
|
+
const inserted = distinctRows(rows.filter((row) => row.operation === 'insert' || row.operation === 'declare'));
|
|
89
|
+
const composed = distinctRows(rows.filter((row) => row.evidenceMode === 'composed'));
|
|
90
|
+
const candidates = composed.length > 1 ? composed : inserted;
|
|
91
|
+
paths = candidates.map(path);
|
|
92
|
+
if (candidates.length > 1) {
|
|
93
|
+
observed = true;
|
|
94
|
+
evidenceLevel = composed.length > 1 ? 'composed' : 'static';
|
|
95
|
+
cause = evidenceLevel === 'static'
|
|
96
|
+
? `${entity} is introduced by ${candidates.length} separate loader declarations. Both inserts use the same id; the final loader outcome is unknown until composition/runtime is observed.`
|
|
97
|
+
: `${entity} occurs more than once in the observed composed tree. These entries share a loader id; runtime rejection was not observed by Doctor.`;
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
unknowns.push('Two independent loader insert paths were not observed. An update or provenance patch chain is not a second insertion.');
|
|
101
|
+
remediation = 'Review the listed bundle declarations and profile patch. Keep one intended insertion for this id after checking its config; use why row and impact bundle for details.';
|
|
102
|
+
}
|
|
103
|
+
else if (pattern === 'config-replacement') {
|
|
104
|
+
const updates = rows.filter((row) => row.operation === 'update' && row.replacement.state !== 'no');
|
|
105
|
+
const replacements = rows.filter((row) => row.evidenceMode === 'composed' && row.replacement.state === 'yes');
|
|
106
|
+
const chain = rows.filter((row) => row.provenance.length > 1);
|
|
107
|
+
if (updates.length > 0 || replacements.length > 0 || chain.length > 0) {
|
|
108
|
+
observed = true;
|
|
109
|
+
evidenceLevel = replacements.length > 0 || chain.some((row) => row.evidenceMode === 'composed') ? 'composed' : 'static';
|
|
110
|
+
cause = replacements.length > 0 ? 'The composition provider explicitly reports a replacement for this row.' : updates.length > 0
|
|
111
|
+
? 'The profile declares a config update for this row. DSH patch semantics replace the whole config rather than deep-merge its keys.'
|
|
112
|
+
: 'The public composition source chain records this row being patched by a later source. Whole-config replacement is unknown without explicit replacement evidence.';
|
|
113
|
+
const previous = rows.filter((row) => row.operation === 'insert' || row.operation === 'declare');
|
|
114
|
+
if (previous.length > 0 && updates.length > 0) {
|
|
115
|
+
const dropped = previous.flatMap((row) => row.configKeys).filter((key) => !updates[updates.length - 1].configKeys.includes(key));
|
|
116
|
+
if (dropped.length > 0)
|
|
117
|
+
cause += ` The later declaration omits ${new Set(dropped).size} earlier config key(s); this is a static difference, not proof of runtime data loss.`;
|
|
118
|
+
}
|
|
119
|
+
unknowns.push('Prior effective values, field ownership and runtime effects are unknown. A source chain alone does not prove removed keys.');
|
|
120
|
+
}
|
|
121
|
+
remediation = 'Review the earlier row and the later patch together. Include required non-secret fields in the complete replacement config, then compare snapshots/diff or run preflight before upgrading.';
|
|
122
|
+
}
|
|
123
|
+
else if (pattern === 'patch-target-missing') {
|
|
124
|
+
const composedMissing = diagnostics.some((item) => item.evidence.some((entry) => entry.evidenceKind === 'composed' && entry.subject === entity));
|
|
125
|
+
const updates = rows.filter((row) => row.operation === 'update');
|
|
126
|
+
const introduction = rows.some((row) => row.operation === 'insert' || row.operation === 'declare');
|
|
127
|
+
if (composedMissing || (updates.length > 0 && !introduction && !rows.some((row) => row.evidenceMode === 'composed'))) {
|
|
128
|
+
observed = true;
|
|
129
|
+
evidenceLevel = composedMissing ? 'composed' : 'static';
|
|
130
|
+
cause = composedMissing ? 'The public dump provider reports that this patch target did not match a composed row.' :
|
|
131
|
+
'A patch targets this id, but no insertion is present in the permitted selected profile metadata. A built-in, home or CLI layer could still supply it; final absence is unknown.';
|
|
132
|
+
}
|
|
133
|
+
remediation = 'Compare the patch id with the intended bundle row. Check whether the bundle is declared and available; inspect home/CLI layers manually if they supply the row.';
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const supporting = diagnostics.filter((item) => pattern !== 'peer-incompatible' || item.id === 'peer-version-mismatch');
|
|
137
|
+
if (supporting.length > 0 && entity !== undefined) {
|
|
138
|
+
observed = true;
|
|
139
|
+
evidenceLevel = 'static';
|
|
140
|
+
cause = pattern === 'bundle-unavailable' ? 'This bundle is declared in dsh.profile.bundles, but the bounded resolver cannot read its package metadata inside the selected profile. An installation-level bundle or external link may still exist.' :
|
|
141
|
+
pattern === 'bundle-patch-unavailable' ? 'The declared bundle has missing, invalid or disallowed patch metadata. Its manifest declaration does not supply an observable loader contribution.' :
|
|
142
|
+
supporting.map((item) => item.evidence.map((entry) => entry.detail).join('; ')).join('; ');
|
|
143
|
+
}
|
|
144
|
+
paths = [...paths, ...diagnostics.flatMap((item) => item.evidence.map((entry) => ({ source: entry.source,
|
|
145
|
+
...(entry.packageName === undefined ? {} : { packageName: entry.packageName }), provenance: [], evidence: entry.evidenceKind })))];
|
|
146
|
+
remediation = pattern === 'peer-incompatible' ? 'Compare the installed plugin peer range with the selected DSH version. Choose a tested compatible pair or rehearse it with preflight; a peer warning alone does not prove a crash.' :
|
|
147
|
+
pattern === 'bundle-unavailable' ? 'Review package.json → dsh.profile.bundles and the intended installed package or link. Confirm installation-level resolution manually before reinstalling anything.' :
|
|
148
|
+
'Review the bundle manifest → dsh.bundle.patch and packaged YAML file. Check the publisher tarball contents; do not substitute or execute a file suggested by the log.';
|
|
149
|
+
if (pattern === 'peer-incompatible' && !observed)
|
|
150
|
+
unknowns.push('The selected runtime version or exact affected package is unknown. Log-reported versions are not trusted runtime facts.');
|
|
151
|
+
}
|
|
152
|
+
if (evidenceLevel === 'static')
|
|
153
|
+
unknowns.push('Final composition: unknown. Home/CLI overlays and installation-level bundles are not inspected by the default static reader.');
|
|
154
|
+
if (entity === undefined)
|
|
155
|
+
unknowns.push('Offending entity: unknown; multiple packages may fit. No package was selected by guessing.');
|
|
156
|
+
const entityObserved = entity !== undefined && (rows.length > 0 || diagnostics.some((item) => diagnosticEntities(item).includes(entity)) || report.compositionFacts?.nodes.some((node) => node.entity === 'bundle' && node.label === entity) === true);
|
|
157
|
+
if (entity !== undefined && !entityObserved)
|
|
158
|
+
unknowns.push('Reported entity: not observed in selected profile/composition metadata.');
|
|
159
|
+
if (paths.length === 0)
|
|
160
|
+
unknowns.push('Provenance: unavailable / not observed.');
|
|
161
|
+
return { pattern, detectedFailure: `${titles[pattern]}${entity === undefined ? '' : `: ${entity}`}`, origin: reported ? 'reported-by-log' : 'observed-from-composition',
|
|
162
|
+
...(!entityObserved || entity === undefined ? {} : { matchedEntity: { kind: rowPattern ? 'row' : 'package', id: entity } }),
|
|
163
|
+
causeStatus: observed ? 'observable-condition' : 'unknown', observableCause: cause,
|
|
164
|
+
provenancePaths: [...new Map(paths.map((entry) => [`${entry.rowKey ?? ''}|${entry.source}|${entry.layer ?? ''}|${entry.packageName ?? ''}`, entry])).values()], evidenceLevel, unknowns, manualRemediation: remediation, relatedDiagnostics: diagnostics };
|
|
165
|
+
}
|
|
166
|
+
/** Pure matching/explanation layer; log strings cannot create composition facts. */
|
|
167
|
+
export function explainFailures({ report, log }) {
|
|
168
|
+
const reported = log !== undefined;
|
|
169
|
+
const signatures = reported ? matchFailureSignatures(log) : [];
|
|
170
|
+
if (!reported) {
|
|
171
|
+
const add = (pattern, entity) => {
|
|
172
|
+
if (safeFailureEntity(entity) !== undefined && !signatures.some((item) => item.pattern === pattern && item.entity === entity))
|
|
173
|
+
signatures.push({ pattern, entity });
|
|
174
|
+
};
|
|
175
|
+
const rows = report.compositionFacts?.rows ?? [];
|
|
176
|
+
for (const row of rows) {
|
|
177
|
+
if (row.id === undefined)
|
|
178
|
+
continue;
|
|
179
|
+
if (rows.filter((other) => other.id === row.id && (other.operation === 'insert' || other.operation === 'declare')).length > 1 ||
|
|
180
|
+
rows.filter((other) => other.id === row.id && other.evidenceMode === 'composed').length > 1)
|
|
181
|
+
add('duplicate-loader-entry', row.id);
|
|
182
|
+
if (row.operation === 'update' && !rows.some((other) => other.id === row.id && (other.operation === 'insert' || other.operation === 'declare')))
|
|
183
|
+
add('patch-target-missing', row.id);
|
|
184
|
+
if ((row.operation === 'update' && row.replacement.state !== 'no') || row.provenance.length > 1 || row.replacement.state === 'yes')
|
|
185
|
+
add('config-replacement', row.id);
|
|
186
|
+
}
|
|
187
|
+
for (const diagnostic of report.diagnostics) {
|
|
188
|
+
for (const pattern of ['bundle-unavailable', 'bundle-patch-unavailable', 'peer-incompatible']) {
|
|
189
|
+
if (!ids[pattern].includes(diagnostic.id) || diagnostic.id === 'peer-version-facts-unavailable')
|
|
190
|
+
continue;
|
|
191
|
+
for (const name of [...new Set(diagnostic.evidence.map((entry) => entry.packageName ?? entry.subject).filter((value) => value !== undefined))])
|
|
192
|
+
add(pattern, name);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const explanations = signatures.slice(0, 50).map((signature) => explain(signature, report, reported));
|
|
197
|
+
const unreadable = report.diagnostics.filter((item) => ['profile-patch-invalid', 'bundle-inventory-manifest-invalid', 'runtime-composition-unavailable', 'composed-dump-parse-failed'].includes(item.id)).map((item) => item.id);
|
|
198
|
+
return { schemaVersion: 1, outcome: explanations.some((item) => item.causeStatus === 'observable-condition') ? 'explained' :
|
|
199
|
+
explanations.length > 0 || (!reported && unreadable.length > 0) ? 'unknown' : reported ? 'no-match' : 'no-supported-issues', profile: '<PROFILE>', runtime: 'not-observed',
|
|
200
|
+
input: { log: reported ? 'reported-by-log' : 'not-provided', recognizedSignatures: signatures.length,
|
|
201
|
+
truncated: signatures.length > 50 || (reported && signatures.length >= 32) || (log !== undefined && new TextEncoder().encode(log).length > maxFailureLogBytes) }, explanations,
|
|
202
|
+
unknowns: ['A recognized log signature is a report, not trusted composition or runtime evidence.', 'An absence of supported findings is not a healthy runtime verification.', ...unreadable.map((id) => `Metadata coverage incomplete: ${id}.`)],
|
|
203
|
+
nextStep: reported ? 'Review the named source paths. If the cause is unknown, verify the profile selection and compare a public dump, why row, snapshot/diff or preflight.' :
|
|
204
|
+
'To focus on a startup symptom: dsh-doctor diagnose --profile <dir> --log <file> (or pipe DSH stderr to diagnose).' };
|
|
205
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { valid, validRange } from 'semver';
|
|
5
|
+
import { resolveComposition } from './composition-adapter.js';
|
|
6
|
+
import { safeFailureEntity } from './failure-explainer.js';
|
|
7
|
+
function record(value) {
|
|
8
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function note(id, source, packageName, detail) {
|
|
11
|
+
return { id, severity: 'warning', title: detail,
|
|
12
|
+
evidence: [{ source, ...(packageName === undefined ? {} : { packageName, subject: packageName }), detail, evidenceKind: 'static' }],
|
|
13
|
+
explanation: detail, remediation: 'Review the declared bundle patch and selected profile. Doctor did not change either.' };
|
|
14
|
+
}
|
|
15
|
+
/** Parse declarations, never simulate a runtime tree or evaluate YAML tags. */
|
|
16
|
+
function declarations(text, source, layer, layerOrder, bundle) {
|
|
17
|
+
const parsed = parse(text, { maxAliasCount: 50, logLevel: 'silent', customTags: [
|
|
18
|
+
{ tag: 'tag:yaml.org,2002:js', resolve: () => '<runtime value: not-run>' },
|
|
19
|
+
{ tag: 'tag:yaml.org,2002:js/function', resolve: () => '<runtime value: not-run>' }
|
|
20
|
+
] });
|
|
21
|
+
const entries = Array.isArray(parsed) ? parsed : layer === 'cordis.yml' && record(parsed) ? parsed.plugins : undefined;
|
|
22
|
+
if (!Array.isArray(entries))
|
|
23
|
+
throw new Error('Patch must be a top-level array');
|
|
24
|
+
const rows = [];
|
|
25
|
+
const add = (entry, operation) => {
|
|
26
|
+
if (!record(entry))
|
|
27
|
+
return;
|
|
28
|
+
rows.push({ ...(typeof entry.id === 'string' && safeFailureEntity(entry.id) !== undefined ? { id: entry.id } : {}),
|
|
29
|
+
...(typeof entry.name === 'string' && safeFailureEntity(entry.name) !== undefined ? { name: entry.name } : {}),
|
|
30
|
+
source, layer, layerOrder, provenance: [bundle ?? source], operation, evidenceKind: 'static',
|
|
31
|
+
...(operation === 'update' && !('config' in entry) ? { replacement: false } : {}),
|
|
32
|
+
...('config' in entry ? { configKeys: record(entry.config) ? Object.keys(entry.config).filter((key) => /^[a-z_][\w.-]{0,127}$/i.test(key)).sort() : [],
|
|
33
|
+
...(operation === 'update' ? { replacement: true, replacementBasis: 'derived' } : {}) } : {}) });
|
|
34
|
+
};
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (!record(entry))
|
|
37
|
+
continue;
|
|
38
|
+
if ('insert' in entry) {
|
|
39
|
+
if (!Array.isArray(entry.insert))
|
|
40
|
+
throw new Error('Insert must be an array');
|
|
41
|
+
for (const inserted of entry.insert)
|
|
42
|
+
add(inserted, 'insert');
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
add(entry, layer === 'cordis.yml' ? 'declare' : 'update');
|
|
46
|
+
}
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
49
|
+
/** Default diagnose reads only selected profile metadata and declared YAML patches.
|
|
50
|
+
* It does not look up executables, launch DSH, import packages or run lifecycles. */
|
|
51
|
+
export async function readFailureComposition(input, composed = false) {
|
|
52
|
+
const rows = [];
|
|
53
|
+
const diagnostics = [...input.inventoryDiagnostics];
|
|
54
|
+
const root = await realpath(input.profileDir);
|
|
55
|
+
for (const [index, pkg] of input.installedPackages.entries()) {
|
|
56
|
+
if (pkg.bundlePatch === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
const path = await realpath(pkg.bundlePatch);
|
|
60
|
+
const rel = relative(root, path);
|
|
61
|
+
// A manifest is untrusted: a patch declaration is not permission to read secrets.
|
|
62
|
+
if (isAbsolute(rel) || rel.startsWith('..') || !/\.(?:yml|yaml)$/i.test(path) ||
|
|
63
|
+
/(?:^|[\\/])(?:\.env[^\\/]*|[^\\/]*(?:credential|token|secret|password)[^\\/]*|sessions?|chats?|workspace)(?:[\\/]|$)/i.test(rel)) {
|
|
64
|
+
diagnostics.push(note('bundle-patch-unsafe', pkg.packageJsonSource, pkg.name, 'Declared patch is outside the permitted YAML metadata surface.'));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const meta = await lstat(path);
|
|
68
|
+
if (!meta.isFile() || meta.size > 1024 * 1024)
|
|
69
|
+
throw new Error('Patch unavailable');
|
|
70
|
+
rows.push(...declarations(await readFile(path, 'utf8'), path, `dsh.profile.bundles[${index}]: ${pkg.name}`, index, pkg.name));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// No parser snippets, values, or raw filesystem error text enter the report.
|
|
74
|
+
diagnostics.push(note('bundle-patch-invalid', pkg.packageJsonSource, pkg.name, 'Declared bundle patch is missing, unreadable, oversized or invalid YAML.'));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const file of input.files) {
|
|
78
|
+
if (file.relativePath !== 'cordis.yml' && file.relativePath !== 'cordis.patch.yml')
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
rows.push(...declarations(file.text, file.relativePath, file.relativePath, input.installedPackages.length + (file.relativePath === 'cordis.yml' ? 0 : 1)));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
diagnostics.push(note('profile-patch-invalid', file.relativePath, undefined, 'Selected profile YAML is invalid or not a supported top-level array.'));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const safeRange = (value) => value !== undefined && value.length <= 256 && validRange(value) !== null ? value : undefined;
|
|
88
|
+
const packages = input.installedPackages.filter((pkg) => safeFailureEntity(pkg.name) !== undefined).map((pkg) => ({
|
|
89
|
+
...pkg, installedVersion: pkg.installedVersion !== undefined && valid(pkg.installedVersion) !== null ? pkg.installedVersion : undefined
|
|
90
|
+
}));
|
|
91
|
+
const peers = packages.flatMap((pkg) => [
|
|
92
|
+
{ packageName: pkg.name, source: pkg.packageJsonSource, evidenceKind: 'static', cordis: safeRange(pkg.peerCordis), node: safeRange(pkg.engineNode) },
|
|
93
|
+
...Object.entries(pkg.dshPeers ?? (pkg.peerDsh === undefined ? {} : { '@deepseek-ai/dsh': pkg.peerDsh })).map(([dshPackage, range]) => ({
|
|
94
|
+
packageName: pkg.name, source: pkg.packageJsonSource, evidenceKind: 'static', dshPackage,
|
|
95
|
+
dsh: ['workspace:^', 'workspace:~', 'workspace:*'].includes(range) ? range : safeRange(range)
|
|
96
|
+
}))
|
|
97
|
+
]);
|
|
98
|
+
const base = { profileDir: root, rows, installedPackages: packages,
|
|
99
|
+
adapterDiagnostics: diagnostics, evidenceMode: 'static', runtimeObservation: { observed: false },
|
|
100
|
+
metadataCoverage: input.metadataCoverage, peerRequirements: peers,
|
|
101
|
+
runtime: { node: process.versions.node, platform: process.platform } };
|
|
102
|
+
if (!composed)
|
|
103
|
+
return base;
|
|
104
|
+
const observed = await resolveComposition(input);
|
|
105
|
+
// Preserve declarations separately via their evidence kind; a dump that dedupes
|
|
106
|
+
// rows cannot upgrade a static duplicate-path finding into a composed collision.
|
|
107
|
+
return { ...base, rows: [...rows, ...observed.rows.filter((row) => row.evidenceKind === 'composed')],
|
|
108
|
+
adapterDiagnostics: [...diagnostics, ...observed.adapterDiagnostics],
|
|
109
|
+
evidenceMode: observed.evidenceMode === 'composed' ? 'mixed' : 'static' };
|
|
110
|
+
}
|
|
@@ -36,6 +36,8 @@ export async function inspectInstalledPackage(profileDir, name, requestedSpec) {
|
|
|
36
36
|
const resolvedSource = await realpath(source);
|
|
37
37
|
if (!within(profileRoot, resolvedSource))
|
|
38
38
|
throw new Error(`Bundle package resolves outside the selected profile: ${name}`);
|
|
39
|
+
if (!/[\\/]package\.json$/i.test(resolvedSource) || /(?:^|[\\/])(?:\.env[^\\/]*|workspace|sessions?|chats?|[^\\/]*(?:credentials?|tokens?|secrets?|password)[^\\/]*)(?:[\\/]|$)/i.test(resolvedSource))
|
|
40
|
+
throw new Error('Bundle manifest is outside the permitted metadata surface');
|
|
39
41
|
const manifest = JSON.parse(await readFile(resolvedSource, 'utf8'));
|
|
40
42
|
const dsh = record(manifest.dsh);
|
|
41
43
|
const bundle = record(dsh?.bundle);
|
|
@@ -44,6 +46,7 @@ export async function inspectInstalledPackage(profileDir, name, requestedSpec) {
|
|
|
44
46
|
const engines = record(manifest.engines);
|
|
45
47
|
const scripts = record(manifest.scripts);
|
|
46
48
|
const peerDsh = string(peers?.['@deepseek-ai/dsh']);
|
|
49
|
+
const dshPeers = Object.fromEntries(Object.entries(peers ?? {}).filter(([key, value]) => /^@deepseek-ai\/dsh(?:-[a-z0-9_.-]+)?$/i.test(key) && typeof value === 'string'));
|
|
47
50
|
const peerCordis = string(peers?.['@deepseek-ai/cordis']) ?? string(peers?.cordis) ?? string(peers?.['@cordisjs/core']);
|
|
48
51
|
const engineNode = string(engines?.node);
|
|
49
52
|
const repository = repositoryValue(manifest.repository);
|
|
@@ -73,6 +76,7 @@ export async function inspectInstalledPackage(profileDir, name, requestedSpec) {
|
|
|
73
76
|
modifiedAt: fileStat.mtime.toISOString(),
|
|
74
77
|
...(Array.isArray(manifest.os) ? { platform: manifest.os.filter((item) => typeof item === 'string') } : {}),
|
|
75
78
|
...(peerDsh === undefined ? {} : { peerDsh }),
|
|
79
|
+
...(Object.keys(dshPeers).length === 0 ? {} : { dshPeers }),
|
|
76
80
|
...(peerCordis === undefined ? {} : { peerCordis }),
|
|
77
81
|
...(engineNode === undefined ? {} : { engineNode }),
|
|
78
82
|
...(scripts === undefined ? {} : { lifecycleScripts: ['preinstall', 'install', 'postinstall', 'prepare'].filter((key) => typeof scripts[key] === 'string') })
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, resolve } from 'node:path';
|
|
4
4
|
import { gunzipSync } from 'node:zlib';
|
|
5
5
|
function text(bytes) {
|
|
@@ -56,7 +56,23 @@ async function atomicWrite(path, bytes) {
|
|
|
56
56
|
const temporaryPath = `${path}.${randomBytes(12).toString('hex')}.partial`;
|
|
57
57
|
try {
|
|
58
58
|
await writeFile(temporaryPath, bytes, { flag: 'wx' });
|
|
59
|
-
|
|
59
|
+
try {
|
|
60
|
+
await rename(temporaryPath, path);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// Windows can refuse a competing rename after another downloader has
|
|
64
|
+
// already published this exact artifact. Accept only identical regular
|
|
65
|
+
// files; never unlink an existing cache entry to work around the race.
|
|
66
|
+
const code = error.code;
|
|
67
|
+
if (code !== 'EPERM' && code !== 'EEXIST')
|
|
68
|
+
throw error;
|
|
69
|
+
const existing = await lstat(path).catch(() => undefined);
|
|
70
|
+
if (!existing?.isFile() || existing.isSymbolicLink())
|
|
71
|
+
throw error;
|
|
72
|
+
const published = await readFile(path).catch(() => undefined);
|
|
73
|
+
if (published === undefined || !published.equals(Buffer.from(bytes)))
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
60
76
|
}
|
|
61
77
|
finally {
|
|
62
78
|
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
package/dist/core/rules.js
CHANGED
|
@@ -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
|
}
|
|
@@ -29,6 +30,8 @@ function satisfiesRange(version, range) {
|
|
|
29
30
|
if (parseVersion(version) === undefined || range.trim().length === 0)
|
|
30
31
|
return undefined;
|
|
31
32
|
try {
|
|
33
|
+
if (['workspace:^', 'workspace:~', 'workspace:*'].includes(range))
|
|
34
|
+
return true;
|
|
32
35
|
return semverSatisfies(version, range, { includePrerelease: true });
|
|
33
36
|
}
|
|
34
37
|
catch {
|
|
@@ -46,7 +49,8 @@ function peerDiagnostics(model) {
|
|
|
46
49
|
if (range === undefined)
|
|
47
50
|
continue;
|
|
48
51
|
const actual = runtime[fact];
|
|
49
|
-
const
|
|
52
|
+
const label = fact === 'dsh' ? peer.dshPackage ?? fact : fact;
|
|
53
|
+
const item = evidence(peer.source, `${peer.packageName}:${fact}`, `requires ${label} ${range}; observed ${actual ?? 'unknown'}`, peer.evidenceKind, peer.packageName);
|
|
50
54
|
if (actual === undefined) {
|
|
51
55
|
diagnostics.push(diagnostic('peer-version-facts-unavailable', 'warning', 'Runtime version fact is unavailable', [item], `The ${fact} version needed to evaluate ${peer.packageName} was not supplied.`, 'Provide the selected runtime version or treat this static compatibility finding as unproven.'));
|
|
52
56
|
continue;
|
|
@@ -85,12 +89,16 @@ function rowKeys(row) {
|
|
|
85
89
|
function rowDiagnostics(rows) {
|
|
86
90
|
const diagnostics = [];
|
|
87
91
|
for (const [id, group] of groups(rows.filter((row) => row.id !== undefined), (row) => row.id)) {
|
|
92
|
+
const inserts = group.filter((row) => row.operation === 'insert' || row.operation === 'declare');
|
|
93
|
+
if (inserts.length > 1) {
|
|
94
|
+
diagnostics.push(diagnostic('duplicate-loader-declarations', 'warning', 'Multiple loader declarations introduce the same row id', inserts.map((row) => evidence(row.source, id, 'An independent declaration introduces this loader id.', row.evidenceKind, row.name)), 'Separate insertion declarations share a loader id; static evidence does not prove the final loader outcome.', 'Review the bundle and profile insertion paths; retain the intended declaration and configuration manually.'));
|
|
95
|
+
}
|
|
88
96
|
const layers = groups(group, (row) => row.layer ?? row.source);
|
|
89
97
|
if (layers.size === 1 && group.length > 1) {
|
|
90
98
|
diagnostics.push(diagnostic('duplicate-row-in-same-layer', 'warning', 'Duplicate composition row id in one layer', group.map((row) => evidence(row.source, id, `row id ${id} is declared more than once in the same layer`, row.evidenceKind, row.name)), `The DSH public row semantics do not establish which declaration should win within one layer for ${id}.`, 'Give the row a unique id or verify the final resolved tree with a public dump provider.'));
|
|
91
99
|
continue;
|
|
92
100
|
}
|
|
93
|
-
if (layers.size < 2)
|
|
101
|
+
if (layers.size < 2 || inserts.length === group.length)
|
|
94
102
|
continue;
|
|
95
103
|
const ordered = [...group].sort((left, right) => (left.layerOrder ?? Number.MAX_SAFE_INTEGER) - (right.layerOrder ?? Number.MAX_SAFE_INTEGER));
|
|
96
104
|
const previous = ordered[ordered.length - 2];
|
|
@@ -157,6 +165,18 @@ export function analyseComposition(model) {
|
|
|
157
165
|
}
|
|
158
166
|
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
167
|
const evidenceMode = runtimeObserved ? 'runtime-observed' : requestedMode === 'runtime-observed' ? 'composed' : requestedMode;
|
|
160
|
-
const
|
|
161
|
-
|
|
168
|
+
const redactedDiagnostics = redactDiagnosticPaths(diagnostics, model);
|
|
169
|
+
const unverifiedFindings = redactedDiagnostics.filter((item) => item.id === 'runtime-composition-unavailable' || item.evidence.some((entry) => entry.evidenceKind === 'static')).map((item) => item.id);
|
|
170
|
+
const reportBase = {
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
evidenceSchemaVersion: 2,
|
|
173
|
+
generatedAt: new Date().toISOString(),
|
|
174
|
+
profileDir: '<PROFILE>',
|
|
175
|
+
evidenceMode,
|
|
176
|
+
runtimeObserved,
|
|
177
|
+
...(model.metadataCoverage === undefined ? {} : { metadataCoverage: model.metadataCoverage }),
|
|
178
|
+
unverifiedFindings,
|
|
179
|
+
diagnostics: redactedDiagnostics
|
|
180
|
+
};
|
|
181
|
+
return { ...reportBase, compositionFacts: buildCompositionFacts({ ...model, evidenceMode }, redactedDiagnostics) };
|
|
162
182
|
}
|
package/dist/core/snapshot.js
CHANGED
|
@@ -53,7 +53,7 @@ function snapshotFromModel(model, input) {
|
|
|
53
53
|
const hashes = Object.fromEntries((input?.files ?? []).filter((file) => ['package.json', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'].includes(file.relativePath)).map((file) => [file.relativePath, file.sha256]));
|
|
54
54
|
return {
|
|
55
55
|
schemaVersion: 2,
|
|
56
|
-
doctorVersion: '0.
|
|
56
|
+
doctorVersion: '0.4.0',
|
|
57
57
|
runtime: { ...(model.runtime?.dsh === undefined ? {} : { dsh: model.runtime.dsh }), ...(model.runtime?.cordis === undefined ? {} : { cordis: model.runtime.cordis }), node: model.runtime?.node ?? process.versions.node, platform: model.runtime?.platform ?? process.platform },
|
|
58
58
|
profile: { ...(profileName === undefined ? {} : { name: profileName }), ...(manifest === undefined ? {} : { manifestHash: manifest.sha256 }) },
|
|
59
59
|
packages: sortJson(packages),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function renderFailure(result, markdown = false) {
|
|
2
|
+
const lines = [markdown ? '# DSH Composition Doctor — Failure Explanation' : 'DSH Composition Doctor — Failure Explanation', '',
|
|
3
|
+
`Profile: ${result.profile}; runtime: ${result.runtime}`, ''];
|
|
4
|
+
if (result.explanations.length === 0)
|
|
5
|
+
lines.push(result.outcome === 'no-match'
|
|
6
|
+
? 'No supported failure signature matched. Cause: unknown.' : result.outcome === 'unknown' ? 'Selected metadata could not be fully inspected. Cause: unknown.' : 'No supported composition issue was observed. Runtime health: unknown.', '');
|
|
7
|
+
for (const item of result.explanations.slice(0, markdown ? 50 : 3)) {
|
|
8
|
+
lines.push(`${markdown ? '## ' : ''}${item.detectedFailure}`, `${item.origin}; evidence: ${item.evidenceLevel}`, '', `Cause: ${item.observableCause}`, '');
|
|
9
|
+
for (const [index, path] of item.provenancePaths.entries()) {
|
|
10
|
+
lines.push(`Path ${index + 1}: ${path.packageName === undefined ? '' : `bundle ${path.packageName} → `}${path.source}`, ` ${path.layer === undefined ? 'layer: unknown' : `layer: ${path.layer}`}${path.rowId === undefined ? '' : ` → row: ${path.rowId}`}`);
|
|
11
|
+
if (path.provenance.length > (markdown ? 0 : 1))
|
|
12
|
+
lines.push(` provenance: ${path.provenance.map((step) => `${step.relation}: ${step.source} (${step.basis})`).join(' → ')}`);
|
|
13
|
+
}
|
|
14
|
+
const unknown = markdown ? item.unknowns.join(' ') : `runtime not observed / not-run${item.evidenceLevel === 'static' ? '; final composition unknown' : ''}${item.causeStatus === 'unknown' ? '; cause unproven' : ''}.`;
|
|
15
|
+
lines.push('', `Unknown: ${unknown}`, `Next: ${item.manualRemediation}`, '');
|
|
16
|
+
}
|
|
17
|
+
if (!markdown && result.explanations.length > 3)
|
|
18
|
+
lines.push(`${result.explanations.length - 3} more explanation(s); use --format json or markdown.`, '');
|
|
19
|
+
if (markdown || result.explanations.length === 0 || result.input.log === 'not-provided')
|
|
20
|
+
lines.push(result.nextStep);
|
|
21
|
+
lines.push('Doctor did not modify your profile.', '');
|
|
22
|
+
return lines.join('\n');
|
|
23
|
+
}
|
package/dist/reports/markdown.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderFailure } from './failure.js';
|
|
1
2
|
function formatEvidence(item) {
|
|
2
3
|
const subject = item.subject === undefined ? '' : ` — ${item.subject}`;
|
|
3
4
|
const packageLabel = item.packageName === undefined ? '' : ` (${item.packageName}${item.version === undefined ? '' : `@${item.version}`})`;
|
|
@@ -9,7 +10,8 @@ function formatDiagnostic(item) {
|
|
|
9
10
|
}
|
|
10
11
|
export function renderMarkdown(report) {
|
|
11
12
|
const summary = ['error', 'warning', 'info'].map((severity) => `${severity}: ${report.diagnostics.filter((item) => item.severity === severity).length}`).join(', ');
|
|
12
|
-
const
|
|
13
|
+
const diagnosticBody = report.diagnostics.length === 0 ? 'No diagnostics were produced.' : report.diagnostics.map(formatDiagnostic).join('\n\n');
|
|
14
|
+
const body = report.failureExplanation === undefined ? diagnosticBody : `${renderFailure(report.failureExplanation, true)}\n${diagnosticBody}`;
|
|
13
15
|
const coverage = report.metadataCoverage === undefined ? '' : `\nMetadata coverage: ${report.metadataCoverage.mode}; unscanned: ${report.metadataCoverage.unscannedSurfaces.join('; ')}\n`;
|
|
14
16
|
return `# DSH Composition Doctor Report\n\nSchema: ${report.schemaVersion}; evidence schema: ${report.evidenceSchemaVersion}\n\nProfile: \`${report.profileDir}\`\n\nGenerated: ${report.generatedAt}\n\nEvidence mode: ${report.evidenceMode}; runtime observed: ${report.runtimeObserved ? 'yes' : 'no'}\n${coverage}\nSummary: ${summary}\n\n${body}\n`;
|
|
15
17
|
}
|
package/dist/reports/schema.js
CHANGED
|
@@ -12,6 +12,73 @@ 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
|
+
failureExplanation: {
|
|
16
|
+
type: 'object', required: ['schemaVersion', 'outcome', 'profile', 'runtime', 'input', 'explanations', 'unknowns', 'nextStep'],
|
|
17
|
+
properties: {
|
|
18
|
+
schemaVersion: { const: 1 }, outcome: { enum: ['explained', 'unknown', 'no-match', 'no-supported-issues'] },
|
|
19
|
+
profile: { const: '<PROFILE>' }, runtime: { const: 'not-observed' },
|
|
20
|
+
input: { type: 'object', required: ['log', 'recognizedSignatures', 'truncated'], properties: {
|
|
21
|
+
log: { enum: ['not-provided', 'reported-by-log'] }, recognizedSignatures: { type: 'integer', minimum: 0 }, truncated: { type: 'boolean' }
|
|
22
|
+
}, additionalProperties: false },
|
|
23
|
+
explanations: { type: 'array', items: {
|
|
24
|
+
type: 'object', required: ['pattern', 'detectedFailure', 'origin', 'causeStatus', 'observableCause', 'provenancePaths', 'evidenceLevel', 'unknowns', 'manualRemediation', 'relatedDiagnostics'],
|
|
25
|
+
properties: {
|
|
26
|
+
pattern: { enum: ['duplicate-loader-entry', 'bundle-unavailable', 'bundle-patch-unavailable', 'patch-target-missing', 'peer-incompatible', 'config-replacement'] },
|
|
27
|
+
detectedFailure: { type: 'string' }, origin: { enum: ['reported-by-log', 'observed-from-composition'] },
|
|
28
|
+
matchedEntity: { type: 'object', required: ['kind', 'id'], properties: { kind: { enum: ['row', 'package'] }, id: { type: 'string' } }, additionalProperties: false },
|
|
29
|
+
causeStatus: { enum: ['observable-condition', 'unknown'] }, observableCause: { type: 'string' },
|
|
30
|
+
provenancePaths: { type: 'array', items: { type: 'object', required: ['source', 'provenance', 'evidence'], properties: {
|
|
31
|
+
source: { type: 'string' }, layer: { type: 'string' }, packageName: { type: 'string' }, rowId: { type: 'string' }, rowKey: { type: 'string' },
|
|
32
|
+
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 } },
|
|
33
|
+
evidence: { enum: ['static', 'composed', 'runtime-observed'] }
|
|
34
|
+
}, additionalProperties: false } },
|
|
35
|
+
evidenceLevel: { enum: ['static', 'composed', 'runtime-observed', 'unknown'] },
|
|
36
|
+
unknowns: { type: 'array', items: { type: 'string' } }, manualRemediation: { type: 'string' },
|
|
37
|
+
relatedDiagnostics: { type: 'array', items: { type: 'object' } }
|
|
38
|
+
}, additionalProperties: false
|
|
39
|
+
} },
|
|
40
|
+
unknowns: { type: 'array', items: { type: 'string' } }, nextStep: { type: 'string' }
|
|
41
|
+
}, additionalProperties: false
|
|
42
|
+
},
|
|
43
|
+
compositionFacts: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
required: ['schemaVersion', 'evidenceMode', 'nodes', 'edges', 'rows', 'unknownSurfaces'],
|
|
46
|
+
properties: {
|
|
47
|
+
schemaVersion: { const: 1 },
|
|
48
|
+
evidenceMode: { enum: ['static', 'composed', 'runtime-observed', 'mixed'] },
|
|
49
|
+
nodes: { type: 'array', items: {
|
|
50
|
+
type: 'object', required: ['id', 'entity', 'label'],
|
|
51
|
+
properties: {
|
|
52
|
+
id: { type: 'string' }, entity: { enum: ['bundle', 'source', 'layer', 'row', 'diagnostic'] }, label: { type: 'string' },
|
|
53
|
+
source: { type: 'string' }, layer: { type: 'string' }, layerOrder: { type: 'number' },
|
|
54
|
+
packageName: { type: 'string' }, packageVersion: { type: 'string' },
|
|
55
|
+
evidenceMode: { enum: ['static', 'composed', 'runtime-observed'] }, rowKey: { type: 'string' }, diagnosticId: { type: 'string' }
|
|
56
|
+
}, additionalProperties: false
|
|
57
|
+
} },
|
|
58
|
+
edges: { type: 'array', items: {
|
|
59
|
+
type: 'object', required: ['from', 'to', 'relation', 'basis', 'evidenceMode'],
|
|
60
|
+
properties: {
|
|
61
|
+
from: { type: 'string' }, to: { type: 'string' },
|
|
62
|
+
relation: { enum: ['introduced', 'patched-by', 'source-of', 'contains', 'diagnosed-by', 'package-source'] },
|
|
63
|
+
basis: { enum: ['observed', 'derived'] }, evidenceMode: { enum: ['static', 'composed', 'runtime-observed'] }
|
|
64
|
+
}, additionalProperties: false
|
|
65
|
+
} },
|
|
66
|
+
rows: { type: 'array', items: {
|
|
67
|
+
type: 'object', required: ['entity', 'key', 'source', 'sourceBasis', 'provenance', 'replacement', 'configKeys', 'configKeysBasis', 'evidenceMode', 'relatedDiagnosticIds', 'unknown'],
|
|
68
|
+
properties: {
|
|
69
|
+
entity: { const: 'row' }, key: { type: 'string' }, id: { type: 'string' }, name: { type: 'string' },
|
|
70
|
+
operation: { enum: ['insert', 'update', 'declare'] },
|
|
71
|
+
source: { type: 'string' }, sourceBasis: { const: 'observed' }, layer: { type: 'string' }, layerOrder: { type: 'number' },
|
|
72
|
+
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 } },
|
|
73
|
+
replacement: { type: 'object', required: ['state', 'basis'], properties: { state: { enum: ['yes', 'no', 'unknown'] }, basis: { enum: ['observed', 'derived', 'unknown'] } }, additionalProperties: false },
|
|
74
|
+
configKeys: { type: 'array', items: { type: 'string' } }, configKeysBasis: { enum: ['observed', 'derived'] }, evidenceMode: { enum: ['static', 'composed', 'runtime-observed'] },
|
|
75
|
+
packageName: { type: 'string' }, packageVersion: { type: 'string' }, relatedDiagnosticIds: { type: 'array', items: { type: 'string' } },
|
|
76
|
+
removedConfigKeys: { type: 'array', items: { type: 'string' } }, unknown: { type: 'array', items: { type: 'string' } }
|
|
77
|
+
}, additionalProperties: false
|
|
78
|
+
} },
|
|
79
|
+
unknownSurfaces: { type: 'array', items: { type: 'string' } }
|
|
80
|
+
}, additionalProperties: false
|
|
81
|
+
},
|
|
15
82
|
diagnostics: {
|
|
16
83
|
type: 'array',
|
|
17
84
|
items: {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-composition-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Explain DSH plugin failures through observable composition paths. Read-only, offline by default, with snapshots and upgrade preflight.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
"test": "pnpm build && vitest run",
|
|
61
61
|
"typecheck": "tsc --noEmit",
|
|
62
62
|
"build": "tsc && node scripts/build-client.mjs",
|
|
63
|
-
"dsh-doctor": "node dist/cli/main.js"
|
|
63
|
+
"dsh-doctor": "node dist/cli/main.js",
|
|
64
|
+
"smoke:pack": "node scripts/pack-smoke.mjs",
|
|
65
|
+
"demo:failure": "node scripts/failure-demo.mjs"
|
|
64
66
|
}
|
|
65
67
|
}
|