dsh-composition-doctor 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +15 -8
- package/README.zh.md +50 -13
- package/dist/cli/main.js +50 -11
- package/dist/client/index.js +1 -1
- package/dist/client/report-view.js +43 -2
- package/dist/client.js +42 -6
- package/dist/core/bundle-inventory.js +66 -0
- package/dist/core/composition-adapter.js +54 -38
- package/dist/core/composition-provenance.js +8 -0
- package/dist/core/diff.js +31 -32
- package/dist/core/dsh-dump-parser.js +111 -0
- package/dist/core/dsh-dump-provider.js +93 -0
- package/dist/core/package-provenance.js +80 -0
- package/dist/core/preflight/artifact-resolver.js +150 -0
- package/dist/core/preflight/candidate-artifact-resolver.js +61 -0
- package/dist/core/preflight/npm-artifact.js +132 -0
- package/dist/core/preflight/package-manager.js +4 -0
- package/dist/core/preflight/package-resolution.js +66 -0
- package/dist/core/preflight/rehearsal-profile.js +6 -0
- package/dist/core/preflight/smoke.js +1 -0
- package/dist/core/preflight.js +64 -11
- package/dist/core/profile-reader.js +8 -1
- package/dist/core/redaction.js +28 -1
- package/dist/core/rules.js +63 -13
- package/dist/core/snapshot.js +60 -46
- package/dist/plugin/index.js +13 -1
- package/dist/reports/markdown.js +2 -2
- package/dist/reports/schema.js +49 -0
- package/package.json +16 -9
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { parse } from 'yaml';
|
|
2
|
-
|
|
2
|
+
import { tryDshDump } from './dsh-dump-provider.js';
|
|
3
|
+
function runtimeUnavailableDiagnostic(detail = 'Static YAML was read, but actual resolved composition requires a public provider.') {
|
|
3
4
|
return {
|
|
4
5
|
id: 'runtime-composition-unavailable',
|
|
5
6
|
severity: 'warning',
|
|
6
7
|
title: 'Runtime composition is unavailable',
|
|
7
8
|
evidence: [],
|
|
8
|
-
explanation:
|
|
9
|
-
remediation: '
|
|
9
|
+
explanation: detail,
|
|
10
|
+
remediation: 'Install or explicitly provide a compatible public DSH dump provider; static findings remain bounded to the allow-listed metadata.'
|
|
10
11
|
};
|
|
11
12
|
}
|
|
12
13
|
function parseFailedDiagnostic(file, error) {
|
|
@@ -14,7 +15,7 @@ function parseFailedDiagnostic(file, error) {
|
|
|
14
15
|
id: 'composition-parse-failed',
|
|
15
16
|
severity: 'warning',
|
|
16
17
|
title: 'Composition YAML could not be parsed',
|
|
17
|
-
evidence: [{ source: file.relativePath, detail: error instanceof Error ? error.message : String(error) }],
|
|
18
|
+
evidence: [{ source: file.relativePath, detail: error instanceof Error ? error.message : String(error), evidenceKind: 'static' }],
|
|
18
19
|
explanation: 'This static composition source was skipped because its YAML is invalid.',
|
|
19
20
|
remediation: 'Correct the YAML syntax and run the static analysis again.'
|
|
20
21
|
};
|
|
@@ -26,6 +27,14 @@ function isPlainObject(value) {
|
|
|
26
27
|
const prototype = Object.getPrototypeOf(value);
|
|
27
28
|
return prototype === Object.prototype || prototype === null;
|
|
28
29
|
}
|
|
30
|
+
function parseYamlValue(text) {
|
|
31
|
+
return parse(text, {
|
|
32
|
+
customTags: [
|
|
33
|
+
{ tag: 'tag:yaml.org,2002:js', resolve: (value) => value },
|
|
34
|
+
{ tag: 'tag:yaml.org,2002:js/function', resolve: (value) => value }
|
|
35
|
+
]
|
|
36
|
+
});
|
|
37
|
+
}
|
|
29
38
|
function cloneProviderValue(value, seen = new WeakMap()) {
|
|
30
39
|
if (value === null || typeof value !== 'object')
|
|
31
40
|
return value;
|
|
@@ -49,7 +58,7 @@ function cloneProviderValue(value, seen = new WeakMap()) {
|
|
|
49
58
|
return copy;
|
|
50
59
|
}
|
|
51
60
|
function staticRows(file) {
|
|
52
|
-
const parsed =
|
|
61
|
+
const parsed = parseYamlValue(file.text);
|
|
53
62
|
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) ? parsed.plugins : undefined;
|
|
54
63
|
if (!Array.isArray(entries))
|
|
55
64
|
return [];
|
|
@@ -66,49 +75,56 @@ function staticRows(file) {
|
|
|
66
75
|
return [row];
|
|
67
76
|
});
|
|
68
77
|
}
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return {};
|
|
78
|
+
function inventoryFacts(input) {
|
|
79
|
+
const bundles = [];
|
|
80
|
+
const peerRequirements = [];
|
|
81
|
+
const platforms = [];
|
|
82
|
+
for (const fact of input.installedPackages) {
|
|
83
|
+
bundles.push({
|
|
84
|
+
name: fact.name,
|
|
85
|
+
source: fact.packageJsonSource,
|
|
86
|
+
evidenceKind: 'static',
|
|
87
|
+
...(fact.requestedSpec === undefined ? {} : { requestedSpec: fact.requestedSpec }),
|
|
88
|
+
...(fact.installedVersion === undefined ? {} : { version: fact.installedVersion }),
|
|
89
|
+
...(fact.gitRef === undefined ? {} : { gitRef: fact.gitRef }),
|
|
90
|
+
...(fact.integrity === undefined ? {} : { integrity: fact.integrity }),
|
|
91
|
+
...(fact.repository === undefined ? {} : { repository: fact.repository }),
|
|
92
|
+
...(fact.modifiedAt === undefined ? {} : { modifiedAt: fact.modifiedAt })
|
|
93
|
+
});
|
|
94
|
+
if (fact.peerDsh !== undefined || fact.peerCordis !== undefined || fact.engineNode !== undefined) {
|
|
95
|
+
peerRequirements.push({ packageName: fact.name, source: fact.packageJsonSource, evidenceKind: 'static', ...(fact.peerDsh === undefined ? {} : { dsh: fact.peerDsh }), ...(fact.peerCordis === undefined ? {} : { cordis: fact.peerCordis }), ...(fact.engineNode === undefined ? {} : { node: fact.engineNode }) });
|
|
96
|
+
}
|
|
97
|
+
if (fact.platform !== undefined && fact.platform.length > 0)
|
|
98
|
+
platforms.push({ packageName: fact.name, source: fact.packageJsonSource, evidenceKind: 'static', supported: fact.platform });
|
|
91
99
|
}
|
|
100
|
+
return { bundles, peerRequirements, platforms };
|
|
92
101
|
}
|
|
93
102
|
export async function resolveComposition(input, provider) {
|
|
94
103
|
if (provider) {
|
|
95
104
|
const providedRows = await provider.resolve(input);
|
|
96
105
|
return {
|
|
97
106
|
profileDir: input.profileDir,
|
|
98
|
-
rows: providedRows.map((row) => ({ ...cloneProviderValue(row), evidenceKind: '
|
|
99
|
-
adapterDiagnostics: [], evidenceMode: 'resolved', metadataCoverage: input.metadataCoverage
|
|
107
|
+
rows: providedRows.map((row) => ({ ...cloneProviderValue(row), evidenceKind: 'composed' })),
|
|
108
|
+
adapterDiagnostics: [...input.inventoryDiagnostics], evidenceMode: 'composed', runtimeObservation: { observed: false, detail: 'A composition provider supplied a resolved tree; no runtime was started.' }, metadataCoverage: input.metadataCoverage, installedPackages: input.installedPackages
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const resolved = await tryDshDump(input);
|
|
112
|
+
if (resolved !== undefined) {
|
|
113
|
+
return {
|
|
114
|
+
profileDir: input.profileDir,
|
|
115
|
+
rows: resolved.rows,
|
|
116
|
+
adapterDiagnostics: [...input.inventoryDiagnostics, ...resolved.diagnostics],
|
|
117
|
+
evidenceMode: 'composed',
|
|
118
|
+
runtimeObservation: { observed: false, detail: 'dump-config composes configuration without starting third-party runtime code.' },
|
|
119
|
+
metadataCoverage: input.metadataCoverage,
|
|
120
|
+
installedPackages: input.installedPackages,
|
|
121
|
+
...inventoryFacts(input)
|
|
100
122
|
};
|
|
101
123
|
}
|
|
102
124
|
const rows = [];
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const platforms = [];
|
|
106
|
-
const adapterDiagnostics = [runtimeUnavailableDiagnostic()];
|
|
125
|
+
const facts = inventoryFacts(input);
|
|
126
|
+
const adapterDiagnostics = [...input.inventoryDiagnostics, runtimeUnavailableDiagnostic()];
|
|
107
127
|
for (const file of input.files) {
|
|
108
|
-
const facts = staticManifestFacts(file);
|
|
109
|
-
bundles.push(...(facts.bundles ?? []));
|
|
110
|
-
peerRequirements.push(...(facts.peerRequirements ?? []));
|
|
111
|
-
platforms.push(...(facts.platforms ?? []));
|
|
112
128
|
if (file.relativePath !== 'cordis.yml' && file.relativePath !== 'cordis.patch.yml')
|
|
113
129
|
continue;
|
|
114
130
|
try {
|
|
@@ -118,5 +134,5 @@ export async function resolveComposition(input, provider) {
|
|
|
118
134
|
adapterDiagnostics.push(parseFailedDiagnostic(file, error));
|
|
119
135
|
}
|
|
120
136
|
}
|
|
121
|
-
return { profileDir: input.profileDir, rows,
|
|
137
|
+
return { profileDir: input.profileDir, rows, ...facts, installedPackages: input.installedPackages, adapterDiagnostics, evidenceMode: 'static', runtimeObservation: { observed: false, detail: 'Only allow-listed static metadata was inspected.' }, metadataCoverage: input.metadataCoverage };
|
|
122
138
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function rowEvidence(row) {
|
|
2
|
+
return {
|
|
3
|
+
source: row.source,
|
|
4
|
+
subject: row.id ?? row.name ?? 'anonymous-row',
|
|
5
|
+
detail: `${row.layer === undefined ? 'resolved row' : `layer ${row.layer}`} ${row.replacement === true ? 'replaces the previous row' : 'declared'}`,
|
|
6
|
+
evidenceKind: row.evidenceKind
|
|
7
|
+
};
|
|
8
|
+
}
|
package/dist/core/diff.js
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { compare as semverCompare } from 'semver';
|
|
2
|
+
import { isSnapshotV2, legacyPlugins } from './snapshot.js';
|
|
2
3
|
function formatChange(change) {
|
|
3
4
|
const before = change.before === undefined ? '' : ` before=${JSON.stringify(change.before)}`;
|
|
4
5
|
const after = change.after === undefined ? '' : ` after=${JSON.stringify(change.after)}`;
|
|
5
6
|
return `- **${change.kind}** \`${change.key}\`${before}${after}`;
|
|
6
7
|
}
|
|
7
|
-
/** Renders the semantic diff without hiding the evidence in a summary-only line. */
|
|
8
8
|
export function renderSnapshotDiffMarkdown(diff) {
|
|
9
|
-
const sections = [
|
|
10
|
-
['Plugins', diff.pluginChanges],
|
|
11
|
-
['Cordis rows', diff.rowChanges],
|
|
12
|
-
['Tool hooks', diff.hookChanges],
|
|
13
|
-
['UI claims', diff.uiConflictChanges],
|
|
14
|
-
['Peer requirements', diff.peerChanges],
|
|
15
|
-
['Platform requirements', diff.platformChanges]
|
|
16
|
-
];
|
|
9
|
+
const sections = [['Plugins', diff.pluginChanges], ['Cordis rows', diff.rowChanges], ['Tool hooks', diff.hookChanges], ['UI claims', diff.uiConflictChanges], ['Peer requirements', diff.peerChanges], ['Platform requirements', diff.platformChanges]];
|
|
17
10
|
const body = sections.map(([title, values]) => `## ${title}\n\n${values.length === 0 ? 'No changes.' : values.map(formatChange).join('\n')}`).join('\n\n');
|
|
18
11
|
const riskItems = diff.upgradeRisk.items.length === 0 ? '- None.' : diff.upgradeRisk.items.map((item) => `- ${item}`).join('\n');
|
|
19
12
|
return `# DSH Composition Doctor Snapshot Diff\n\n${diff.upgradeRisk.summary}\n\n## Upgrade risk items\n\n${riskItems}\n\n${body}\n`;
|
|
@@ -22,13 +15,7 @@ function compareVersions(left, right) {
|
|
|
22
15
|
if (left === undefined || right === undefined)
|
|
23
16
|
return undefined;
|
|
24
17
|
try {
|
|
25
|
-
|
|
26
|
-
return -1;
|
|
27
|
-
if (semverSatisfies(left, `>${right}`, { includePrerelease: true }))
|
|
28
|
-
return 1;
|
|
29
|
-
if (semverSatisfies(left, `=${right}`, { includePrerelease: true }))
|
|
30
|
-
return 0;
|
|
31
|
-
return undefined;
|
|
18
|
+
return semverCompare(left, right, { includePrerelease: true });
|
|
32
19
|
}
|
|
33
20
|
catch {
|
|
34
21
|
return undefined;
|
|
@@ -38,38 +25,50 @@ function changes(before, after, beforeKey, afterKey = beforeKey) {
|
|
|
38
25
|
const previous = new Map(before.map((item) => [beforeKey(item), item]));
|
|
39
26
|
const next = new Map(after.map((item) => [afterKey(item), item]));
|
|
40
27
|
const result = [];
|
|
41
|
-
for (const
|
|
42
|
-
const left = previous.get(
|
|
43
|
-
const right = next.get(
|
|
28
|
+
for (const key of [...new Set([...previous.keys(), ...next.keys()])].sort()) {
|
|
29
|
+
const left = previous.get(key);
|
|
30
|
+
const right = next.get(key);
|
|
44
31
|
if (left === undefined)
|
|
45
|
-
result.push({ kind: 'added', key
|
|
32
|
+
result.push({ kind: 'added', key, after: right });
|
|
46
33
|
else if (right === undefined)
|
|
47
|
-
result.push({ kind: 'removed', key
|
|
34
|
+
result.push({ kind: 'removed', key, before: left });
|
|
48
35
|
else if (JSON.stringify(left) !== JSON.stringify(right))
|
|
49
|
-
result.push({ kind: 'changed', key
|
|
36
|
+
result.push({ kind: 'changed', key, before: left, after: right });
|
|
50
37
|
}
|
|
51
38
|
return result;
|
|
52
39
|
}
|
|
40
|
+
function normalize(snapshot) {
|
|
41
|
+
if (isSnapshotV2(snapshot)) {
|
|
42
|
+
return {
|
|
43
|
+
plugins: legacyPlugins(snapshot), rows: snapshot.rows, hooks: snapshot.hooks, uiClaims: snapshot.uiClaims,
|
|
44
|
+
peers: snapshot.packages.map((item) => ({ packageName: item.name, source: item.packageJsonSource, dsh: item.peerDsh, cordis: item.peerCordis, node: item.engineNode })),
|
|
45
|
+
platforms: snapshot.packages.map((item) => ({ packageName: item.name, source: item.packageJsonSource, supported: item.platform })), identityPrefix: ''
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { plugins: snapshot.plugins, rows: snapshot.rows, hooks: snapshot.hooks, uiClaims: snapshot.uiClaims, peers: snapshot.peers, platforms: snapshot.platforms, identityPrefix: `${snapshot.profile.path}:${snapshot.profile.packageName ?? ''}:` };
|
|
49
|
+
}
|
|
53
50
|
export function diffSnapshots(before, after) {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
51
|
+
const left = normalize(before);
|
|
52
|
+
const right = normalize(after);
|
|
53
|
+
const pluginKey = (prefix, item) => `${prefix}${item.name}:${item.source}`;
|
|
54
|
+
const pluginChanges = changes(left.plugins, right.plugins, (item) => pluginKey(left.identityPrefix, item), (item) => pluginKey(right.identityPrefix, item)).map((change) => {
|
|
56
55
|
const name = change.before?.name ?? change.after?.name ?? change.key;
|
|
57
56
|
if (change.kind !== 'changed')
|
|
58
57
|
return { ...change, name };
|
|
59
58
|
const comparison = compareVersions(change.before?.version, change.after?.version);
|
|
60
59
|
return { ...change, name, kind: comparison === undefined ? 'changed' : comparison < 0 ? 'upgraded' : comparison > 0 ? 'downgraded' : 'changed' };
|
|
61
60
|
});
|
|
62
|
-
const rowChanges = changes(
|
|
63
|
-
const hookChanges = changes(
|
|
64
|
-
const uiConflictChanges = changes(
|
|
65
|
-
const peerChanges = changes(
|
|
66
|
-
const platformChanges = changes(
|
|
61
|
+
const rowChanges = changes(left.rows, right.rows, (item) => `${item.source}:${item.id ?? item.name ?? ''}`);
|
|
62
|
+
const hookChanges = changes(left.hooks, right.hooks, (item) => `${item.source}:${item.hook}:${item.packageName ?? ''}`);
|
|
63
|
+
const uiConflictChanges = changes(left.uiClaims, right.uiClaims, (item) => `${item.kind}:${item.value}:${item.source}`);
|
|
64
|
+
const peerChanges = changes(left.peers, right.peers, (item) => `${item.source ?? ''}:${item.packageName ?? ''}`);
|
|
65
|
+
const platformChanges = changes(left.platforms, right.platforms, (item) => `${item.source ?? ''}:${item.packageName ?? ''}`);
|
|
67
66
|
const items = pluginChanges.filter((change) => change.kind === 'downgraded' || change.kind === 'upgraded').map((change) => `${change.kind} plugin ${change.name}`);
|
|
68
67
|
if (hookChanges.length > 0)
|
|
69
|
-
items.push('hook registrations changed;
|
|
68
|
+
items.push('hook registrations changed; runtime ordering remains unverified');
|
|
70
69
|
if (uiConflictChanges.length > 0)
|
|
71
70
|
items.push('UI claims changed; verify ownership conflicts before upgrade');
|
|
72
71
|
if (peerChanges.length > 0 || platformChanges.length > 0)
|
|
73
72
|
items.push('peer or platform requirements changed; verify supported runtime facts');
|
|
74
|
-
return { pluginChanges, rowChanges, hookChanges, uiConflictChanges, peerChanges, platformChanges, upgradeRisk: { summary: items.length === 0 ? 'No
|
|
73
|
+
return { pluginChanges, rowChanges, hookChanges, uiConflictChanges, peerChanges, platformChanges, upgradeRisk: { summary: items.length === 0 ? 'No risk indicators changed within the evidence covered by these snapshots.' : `Risk indicators changed: ${items.join('; ')}. No migration was executed.`, items } };
|
|
75
74
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
function rowsFrom(value) {
|
|
6
|
+
if (Array.isArray(value))
|
|
7
|
+
return value;
|
|
8
|
+
const object = record(value);
|
|
9
|
+
if (object === undefined)
|
|
10
|
+
return [];
|
|
11
|
+
for (const key of ['rows', 'plugins', 'composition']) {
|
|
12
|
+
if (Array.isArray(object[key]))
|
|
13
|
+
return object[key];
|
|
14
|
+
}
|
|
15
|
+
const config = record(object.config);
|
|
16
|
+
if (config !== undefined && Array.isArray(config.plugins))
|
|
17
|
+
return config.plugins;
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
function configKeys(value) {
|
|
21
|
+
const object = record(value);
|
|
22
|
+
return object === undefined ? undefined : Object.keys(object).sort();
|
|
23
|
+
}
|
|
24
|
+
function provenanceByRow(stdout) {
|
|
25
|
+
const result = [];
|
|
26
|
+
let current;
|
|
27
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
28
|
+
const comment = line.match(/^\s*#\s*==\s*(.+?)\s*$/);
|
|
29
|
+
if (comment !== null) {
|
|
30
|
+
current = comment[1];
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (current !== undefined && /^-\s/.test(line))
|
|
34
|
+
result.push(current);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
function provenanceParts(label) {
|
|
39
|
+
if (label === undefined)
|
|
40
|
+
return {};
|
|
41
|
+
const parts = label.split(/,\s*patched by\s+/);
|
|
42
|
+
const source = parts[0];
|
|
43
|
+
const patches = parts.slice(1).flatMap((part) => part.split(/,\s+/)).filter(Boolean);
|
|
44
|
+
return { source, provenance: [source, ...patches] };
|
|
45
|
+
}
|
|
46
|
+
export function parseDshDump(stdout, stderr = '') {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(stdout);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
parsed = parse(stdout, {
|
|
54
|
+
customTags: [
|
|
55
|
+
{ tag: 'tag:yaml.org,2002:js', resolve: (value) => value },
|
|
56
|
+
{ tag: 'tag:yaml.org,2002:js/function', resolve: (value) => value }
|
|
57
|
+
]
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return {
|
|
63
|
+
rows: [],
|
|
64
|
+
diagnostics: [{
|
|
65
|
+
id: 'composed-dump-parse-failed', severity: 'warning', title: 'Composed DSH dump could not be parsed',
|
|
66
|
+
evidence: [{ source: '<DSH dump>', detail: error instanceof Error ? error.message : String(error), evidenceKind: 'composed' }],
|
|
67
|
+
explanation: 'The public dump-config command returned output that was not valid JSON or YAML.',
|
|
68
|
+
remediation: 'Use a verified DSH release whose public dump-config output is supported by this adapter.'
|
|
69
|
+
}]
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const provenanceHints = provenanceByRow(stdout);
|
|
73
|
+
const legacyProvenanceHints = stdout.split(/\r?\n/).flatMap((line) => {
|
|
74
|
+
const match = line.match(/^\s*#\s*(?:source|from|file|layer)\s*[:=]\s*(.+?)\s*$/i);
|
|
75
|
+
return match === null ? [] : [match[1]];
|
|
76
|
+
});
|
|
77
|
+
const rawRows = rowsFrom(parsed);
|
|
78
|
+
const seenIds = new Map();
|
|
79
|
+
const rows = rawRows.flatMap((entry, index) => {
|
|
80
|
+
const object = record(entry);
|
|
81
|
+
if (object === undefined)
|
|
82
|
+
return [];
|
|
83
|
+
const provenance = provenanceParts(typeof object.source === 'string' ? object.source : provenanceHints[index] ?? legacyProvenanceHints[index]);
|
|
84
|
+
const source = typeof object.source === 'string' ? object.source : provenance.source ?? '<DSH dump>';
|
|
85
|
+
const config = object.config;
|
|
86
|
+
const row = {
|
|
87
|
+
...(typeof object.id === 'string' ? { id: object.id } : {}),
|
|
88
|
+
...(typeof object.name === 'string' ? { name: object.name } : typeof object.plugin === 'string' ? { name: object.plugin } : {}),
|
|
89
|
+
source,
|
|
90
|
+
...(config === undefined ? {} : { config, ...(configKeys(config) === undefined ? {} : { configKeys: configKeys(config) }) }),
|
|
91
|
+
evidenceKind: 'composed',
|
|
92
|
+
...(typeof object.layer === 'string' ? { layer: object.layer } : {}),
|
|
93
|
+
...(typeof object.layerOrder === 'number' ? { layerOrder: object.layerOrder } : {}),
|
|
94
|
+
...(provenance.provenance === undefined ? {} : { provenance: provenance.provenance }),
|
|
95
|
+
...(object.replacement === true || object.overridden === true || (provenance.provenance !== undefined && provenance.provenance.length > 1) || (typeof object.id === 'string' && seenIds.has(object.id) && seenIds.get(object.id) !== source) ? { replacement: true } : {})
|
|
96
|
+
};
|
|
97
|
+
if (row.id !== undefined)
|
|
98
|
+
seenIds.set(row.id, row.source);
|
|
99
|
+
return [row];
|
|
100
|
+
});
|
|
101
|
+
const diagnostics = [];
|
|
102
|
+
const unmatched = stderr.match(/(?:patch[^\r\n]*(?:unmatched|unknown|not found)|(?:unmatched|unknown|not found)[^\r\n]*(?:patch|row|target))[^\r\n]*/gi) ?? [];
|
|
103
|
+
for (const detail of unmatched)
|
|
104
|
+
diagnostics.push({
|
|
105
|
+
id: 'unmatched-patch-target', severity: 'warning', title: 'Resolved patch target did not match a row',
|
|
106
|
+
evidence: [{ source: '<DSH stderr>', detail, evidenceKind: 'composed' }],
|
|
107
|
+
explanation: 'The public DSH dump reported a patch target that did not match a resolved row.',
|
|
108
|
+
remediation: 'Verify the patch row id against the selected bundle/profile composition.'
|
|
109
|
+
});
|
|
110
|
+
return { rows, diagnostics };
|
|
111
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { extname, join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { redactYamlPreservingTags, redact } from './redaction.js';
|
|
7
|
+
import { parseDshDump } from './dsh-dump-parser.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const yamlFiles = new Set(['cordis.yml', 'cordis.patch.yml']);
|
|
10
|
+
function unavailable(reason) {
|
|
11
|
+
return {
|
|
12
|
+
id: 'runtime-composition-unavailable', severity: 'warning', title: 'Resolved composition is unavailable', evidence: [{ source: '<DSH provider>', detail: reason, evidenceKind: 'static' }],
|
|
13
|
+
explanation: 'The scan could not obtain final resolved rows from a public DSH dump-config provider, so it used bounded static evidence.',
|
|
14
|
+
remediation: 'Install a verified DSH CLI or pass a public provider in integration code; do not import private DSH loaders.'
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function pathCandidates() {
|
|
18
|
+
const values = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
|
|
19
|
+
return values.flatMap((directory) => process.platform === 'win32' ? [join(directory, 'dsh.cmd'), join(directory, 'dsh.exe'), join(directory, 'dsh')] : [join(directory, 'dsh')]);
|
|
20
|
+
}
|
|
21
|
+
async function findDsh() {
|
|
22
|
+
const explicit = process.env.DSH_DOCTOR_DSH_BIN;
|
|
23
|
+
const candidates = explicit === undefined ? pathCandidates() : [explicit];
|
|
24
|
+
for (const candidate of candidates) {
|
|
25
|
+
try {
|
|
26
|
+
if ((await stat(candidate)).isFile())
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
catch { /* executable lookup is best effort */ }
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
async function copyRehearsal(input, directory) {
|
|
34
|
+
for (const file of input.files) {
|
|
35
|
+
if (file.relativePath === 'package.json') {
|
|
36
|
+
await writeFile(join(directory, file.relativePath), `${JSON.stringify(redact(JSON.parse(file.text)), null, 2)}\n`, 'utf8');
|
|
37
|
+
}
|
|
38
|
+
else if (yamlFiles.has(file.relativePath)) {
|
|
39
|
+
await writeFile(join(directory, file.relativePath), redactYamlPreservingTags(file.text), 'utf8');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function runtimeUnknownDiagnostic(input) {
|
|
44
|
+
const tagged = input.files.find((file) => yamlFiles.has(file.relativePath) && /!<[^>]+>|!![A-Za-z0-9_/-]+/.test(file.text));
|
|
45
|
+
if (tagged === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
return {
|
|
48
|
+
id: 'runtime-behavior-unverified', severity: 'warning', title: 'Runtime-dependent configuration remains unverified',
|
|
49
|
+
evidence: [{ source: tagged.relativePath, detail: 'A custom YAML tag or runtime expression was preserved for dump-config but was not executed.', evidenceKind: 'composed' }],
|
|
50
|
+
explanation: 'A composed configuration can be structurally resolved while runtime-dependent values remain unknown. dump-config is not a runtime observation.',
|
|
51
|
+
remediation: 'Keep runtime-observed claims separate unless a supported isolated runtime observation backend is explicitly available.'
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function tryDshDump(input, options = {}) {
|
|
55
|
+
const dshBin = options.dshBin ?? await findDsh();
|
|
56
|
+
if (dshBin === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
const directory = await mkdtemp(join(tmpdir(), 'dsh-doctor-dump-'));
|
|
59
|
+
try {
|
|
60
|
+
const home = join(directory, 'dsh-home');
|
|
61
|
+
const isolatedName = 'doctor-isolated';
|
|
62
|
+
const profile = join(home, 'profiles', isolatedName);
|
|
63
|
+
await mkdir(profile, { recursive: true });
|
|
64
|
+
await copyRehearsal(input, profile);
|
|
65
|
+
const env = { PATH: process.env.PATH, TEMP: process.env.TEMP, TMP: process.env.TMP, DSH_HOME: home };
|
|
66
|
+
const extension = extname(dshBin).toLowerCase();
|
|
67
|
+
const script = ['.js', '.mjs', '.cjs'].includes(extension);
|
|
68
|
+
const commandShim = process.platform === 'win32' && (extension === '.cmd' || extension === '.bat');
|
|
69
|
+
const executable = script ? process.execPath : commandShim ? (process.env.ComSpec ?? 'cmd.exe') : dshBin;
|
|
70
|
+
const args = script ? [dshBin, '--profile', isolatedName, '--dump-config'] : commandShim ? ['/d', '/s', '/c', `call "${dshBin}" --profile ${isolatedName} --dump-config`] : ['--profile', isolatedName, '--dump-config'];
|
|
71
|
+
try {
|
|
72
|
+
const result = await execFileAsync(executable, args, { cwd: directory, env, timeout: options.timeoutMs ?? 20_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 });
|
|
73
|
+
const parsed = parseDshDump(result.stdout, result.stderr);
|
|
74
|
+
const runtimeUnknown = runtimeUnknownDiagnostic(input);
|
|
75
|
+
return { rows: parsed.rows, diagnostics: runtimeUnknown === undefined ? parsed.diagnostics : [...parsed.diagnostics, runtimeUnknown] };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
79
|
+
// The caller must keep the static rows when the public command cannot
|
|
80
|
+
// execute. A default provider failure is represented by the bounded
|
|
81
|
+
// static-fallback diagnostic in composition-adapter.
|
|
82
|
+
void detail;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
void error;
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await rm(directory, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
function record(value) {
|
|
5
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
6
|
+
}
|
|
7
|
+
function string(value) {
|
|
8
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
function repositoryValue(value) {
|
|
11
|
+
if (typeof value === 'string')
|
|
12
|
+
return value;
|
|
13
|
+
const object = record(value);
|
|
14
|
+
return object === undefined ? undefined : string(object.url);
|
|
15
|
+
}
|
|
16
|
+
function gitRefFrom(repository, manifest) {
|
|
17
|
+
const explicit = string(manifest.gitHead);
|
|
18
|
+
if (explicit !== undefined)
|
|
19
|
+
return explicit;
|
|
20
|
+
const match = repository?.match(/#([0-9a-f]{7,40})$/i);
|
|
21
|
+
return match?.[1];
|
|
22
|
+
}
|
|
23
|
+
function packageJsonPath(profileDir, name) {
|
|
24
|
+
if (!/^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/i.test(name))
|
|
25
|
+
throw new Error(`Invalid bundle package name: ${name}`);
|
|
26
|
+
const require = createRequire(resolve(profileDir, 'package.json'));
|
|
27
|
+
return require.resolve(`${name}/package.json`);
|
|
28
|
+
}
|
|
29
|
+
function within(root, candidate) {
|
|
30
|
+
const relativePath = relative(root, candidate);
|
|
31
|
+
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
|
32
|
+
}
|
|
33
|
+
export async function inspectInstalledPackage(profileDir, name, requestedSpec) {
|
|
34
|
+
const profileRoot = await realpath(profileDir);
|
|
35
|
+
const source = packageJsonPath(profileDir, name);
|
|
36
|
+
const resolvedSource = await realpath(source);
|
|
37
|
+
if (!within(profileRoot, resolvedSource))
|
|
38
|
+
throw new Error(`Bundle package resolves outside the selected profile: ${name}`);
|
|
39
|
+
const manifest = JSON.parse(await readFile(resolvedSource, 'utf8'));
|
|
40
|
+
const dsh = record(manifest.dsh);
|
|
41
|
+
const bundle = record(dsh?.bundle);
|
|
42
|
+
const dist = record(manifest.dist);
|
|
43
|
+
const peers = record(manifest.peerDependencies);
|
|
44
|
+
const engines = record(manifest.engines);
|
|
45
|
+
const scripts = record(manifest.scripts);
|
|
46
|
+
const peerDsh = string(peers?.['@deepseek-ai/dsh']);
|
|
47
|
+
const peerCordis = string(peers?.['@deepseek-ai/cordis']) ?? string(peers?.cordis) ?? string(peers?.['@cordisjs/core']);
|
|
48
|
+
const engineNode = string(engines?.node);
|
|
49
|
+
const repository = repositoryValue(manifest.repository);
|
|
50
|
+
const fileStat = await stat(source);
|
|
51
|
+
const declaredPatch = string(bundle?.patch);
|
|
52
|
+
const patchPath = declaredPatch === undefined ? undefined : resolve(dirname(resolvedSource), declaredPatch);
|
|
53
|
+
if (patchPath !== undefined && !within(profileRoot, patchPath))
|
|
54
|
+
throw new Error(`Bundle patch resolves outside the selected profile: ${name}`);
|
|
55
|
+
const safePatch = patchPath === undefined ? undefined : await realpath(patchPath).then((resolvedPatch) => {
|
|
56
|
+
if (!within(profileRoot, resolvedPatch))
|
|
57
|
+
throw new Error(`Bundle patch resolves outside the selected profile: ${name}`);
|
|
58
|
+
return resolvedPatch;
|
|
59
|
+
}).catch((error) => {
|
|
60
|
+
if (error.code === 'ENOENT')
|
|
61
|
+
return patchPath;
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
...(requestedSpec === undefined ? {} : { requestedSpec }),
|
|
67
|
+
...(string(manifest.version) === undefined ? {} : { installedVersion: string(manifest.version) }),
|
|
68
|
+
packageJsonSource: source,
|
|
69
|
+
...(safePatch === undefined ? {} : { bundlePatch: safePatch }),
|
|
70
|
+
...(repository === undefined ? {} : { repository }),
|
|
71
|
+
...(gitRefFrom(repository, manifest) === undefined ? {} : { gitRef: gitRefFrom(repository, manifest) }),
|
|
72
|
+
...(string(dist?.integrity) === undefined ? {} : { integrity: string(dist?.integrity) }),
|
|
73
|
+
modifiedAt: fileStat.mtime.toISOString(),
|
|
74
|
+
...(Array.isArray(manifest.os) ? { platform: manifest.os.filter((item) => typeof item === 'string') } : {}),
|
|
75
|
+
...(peerDsh === undefined ? {} : { peerDsh }),
|
|
76
|
+
...(peerCordis === undefined ? {} : { peerCordis }),
|
|
77
|
+
...(engineNode === undefined ? {} : { engineNode }),
|
|
78
|
+
...(scripts === undefined ? {} : { lifecycleScripts: ['preinstall', 'install', 'postinstall', 'prepare'].filter((key) => typeof scripts[key] === 'string') })
|
|
79
|
+
};
|
|
80
|
+
}
|