dsh-composition-doctor 0.1.2 → 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 +23 -7
- package/README.zh.md +56 -11
- package/dist/cli/main.js +66 -18
- package/dist/client/index.js +5 -4
- package/dist/client/report-view.js +110 -11
- package/dist/client.js +112 -18
- package/dist/core/bundle-inventory.js +66 -0
- package/dist/core/composition-adapter.js +56 -9
- package/dist/core/composition-provenance.js +8 -0
- package/dist/core/diff.js +41 -36
- 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 +98 -11
- package/dist/core/profile-reader.js +16 -1
- package/dist/core/redaction.js +28 -1
- package/dist/core/rules.js +64 -12
- package/dist/core/snapshot.js +60 -46
- package/dist/plugin/index.js +16 -3
- package/dist/reports/location.js +9 -0
- package/dist/reports/markdown.js +3 -2
- package/dist/reports/schema.js +49 -0
- package/package.json +18 -10
package/dist/core/preflight.js
CHANGED
|
@@ -2,17 +2,20 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
|
-
import { parse, stringify } from 'yaml';
|
|
6
5
|
import { resolveComposition } from './composition-adapter.js';
|
|
6
|
+
import { tryDshDump } from './dsh-dump-provider.js';
|
|
7
7
|
import { readProfile } from './profile-reader.js';
|
|
8
|
-
import { redact } from './redaction.js';
|
|
8
|
+
import { redact, redactYamlPreservingTags } from './redaction.js';
|
|
9
9
|
import { analyseComposition } from './rules.js';
|
|
10
|
+
import { resolveDshArtifact } from './preflight/artifact-resolver.js';
|
|
11
|
+
import { resolveCandidateArtifact } from './preflight/candidate-artifact-resolver.js';
|
|
12
|
+
import { evaluatePackageResolution } from './preflight/package-resolution.js';
|
|
10
13
|
const yamlFiles = new Set(['cordis.yml', 'cordis.patch.yml']);
|
|
11
14
|
const lockFiles = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']);
|
|
12
15
|
const packageNamePattern = /^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/i;
|
|
13
16
|
const versionPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?$/i;
|
|
14
17
|
function evidence(source, subject, detail, severity) {
|
|
15
|
-
return { source, subject, detail, ...(severity === undefined ? {} : { severity }) };
|
|
18
|
+
return { source, subject, detail, evidenceKind: 'static', ...(severity === undefined ? {} : { severity }) };
|
|
16
19
|
}
|
|
17
20
|
/** A digest of the allow-listed metadata surface; file contents are never returned. */
|
|
18
21
|
export function profileFingerprint(input) {
|
|
@@ -30,13 +33,17 @@ function safeCandidate(value) {
|
|
|
30
33
|
const version = value.slice(at + 1);
|
|
31
34
|
return packageNamePattern.test(name) && versionPattern.test(version);
|
|
32
35
|
}
|
|
36
|
+
function candidateParts(value) {
|
|
37
|
+
const at = value.lastIndexOf('@');
|
|
38
|
+
return { name: value.slice(0, at), version: value.slice(at + 1) };
|
|
39
|
+
}
|
|
33
40
|
function safeMetadataText(relativePath, text) {
|
|
34
41
|
try {
|
|
35
42
|
if (relativePath === 'package.json') {
|
|
36
43
|
return `${JSON.stringify(redact(JSON.parse(text)), null, 2)}\n`;
|
|
37
44
|
}
|
|
38
45
|
if (yamlFiles.has(relativePath)) {
|
|
39
|
-
return
|
|
46
|
+
return redactYamlPreservingTags(text);
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
catch {
|
|
@@ -45,7 +52,8 @@ function safeMetadataText(relativePath, text) {
|
|
|
45
52
|
}
|
|
46
53
|
return undefined;
|
|
47
54
|
}
|
|
48
|
-
async function prepareIsolatedProfile(input, directory, evidenceItems) {
|
|
55
|
+
async function prepareIsolatedProfile(input, directory, candidates, evidenceItems) {
|
|
56
|
+
let wroteManifest = false;
|
|
49
57
|
for (const file of input.files) {
|
|
50
58
|
if (lockFiles.has(file.relativePath)) {
|
|
51
59
|
evidenceItems.push(evidence(file.relativePath, 'lockfile-not-copied', 'Lockfile content is excluded from the rehearsal directory; its source hash remains available in snapshots.'));
|
|
@@ -56,7 +64,28 @@ async function prepareIsolatedProfile(input, directory, evidenceItems) {
|
|
|
56
64
|
evidenceItems.push(evidence(file.relativePath, 'metadata-not-copied', 'Metadata was not copied because it could not be safely parsed.'));
|
|
57
65
|
continue;
|
|
58
66
|
}
|
|
67
|
+
if (file.relativePath === 'package.json' && candidates.length > 0) {
|
|
68
|
+
const manifest = JSON.parse(safeText);
|
|
69
|
+
const dependencies = manifest.dependencies !== null && typeof manifest.dependencies === 'object' ? manifest.dependencies : {};
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
const { name, version } = candidateParts(candidate);
|
|
72
|
+
dependencies[name] = version;
|
|
73
|
+
}
|
|
74
|
+
manifest.dependencies = dependencies;
|
|
75
|
+
await writeFile(join(directory, file.relativePath), `${JSON.stringify(manifest, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
76
|
+
wroteManifest = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
59
79
|
await writeFile(join(directory, file.relativePath), safeText, { encoding: 'utf8', flag: 'wx' });
|
|
80
|
+
if (file.relativePath === 'package.json')
|
|
81
|
+
wroteManifest = true;
|
|
82
|
+
}
|
|
83
|
+
if (!wroteManifest && candidates.length > 0) {
|
|
84
|
+
const dependencies = Object.fromEntries(candidates.map((candidate) => {
|
|
85
|
+
const { name, version } = candidateParts(candidate);
|
|
86
|
+
return [name, version];
|
|
87
|
+
}));
|
|
88
|
+
await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name: 'dsh-doctor-isolated-profile', private: true, dependencies }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
60
89
|
}
|
|
61
90
|
}
|
|
62
91
|
function runtimeForTarget(model, targetDsh) {
|
|
@@ -88,6 +117,9 @@ export async function runPreflight(options) {
|
|
|
88
117
|
let tempDirectory;
|
|
89
118
|
let smokeOutcome = 'pass';
|
|
90
119
|
let smokeDetail = 'No isolated smoke test was run.';
|
|
120
|
+
let packageResolutionOutcome = 'not-run';
|
|
121
|
+
let packageResolutionDetail = 'No candidate package was supplied.';
|
|
122
|
+
const packageResolutions = [];
|
|
91
123
|
let diagnostics = [];
|
|
92
124
|
let profileDir = requestedProfile;
|
|
93
125
|
let extraWarning = false;
|
|
@@ -96,7 +128,11 @@ export async function runPreflight(options) {
|
|
|
96
128
|
evidenceItems.push(evidence(requestedProfile, 'target-dsh-invalid', 'Target DSH version must be an explicit semantic version.', 'error'));
|
|
97
129
|
return {
|
|
98
130
|
schemaVersion: 1, outcome: 'fail', profileDir: requestedProfile, targetDsh: options.targetDsh,
|
|
99
|
-
candidates: [], evidence: evidenceItems, diagnostics, smokeTest: { outcome: 'fail', detail: 'The target DSH version was rejected before rehearsal.' }
|
|
131
|
+
candidates: [], evidence: evidenceItems, diagnostics, smokeTest: { outcome: 'fail', detail: 'The target DSH version was rejected before rehearsal.' },
|
|
132
|
+
compositionSmoke: { outcome: 'fail', detail: 'The target DSH version was rejected before rehearsal.' },
|
|
133
|
+
packageResolutionSmoke: { outcome: 'not-run', detail: 'Target version validation failed before package resolution.' },
|
|
134
|
+
packageResolutions: [],
|
|
135
|
+
runtimeSmoke: { outcome: 'not-run', detail: 'Runtime execution was not attempted.' }
|
|
100
136
|
};
|
|
101
137
|
}
|
|
102
138
|
const input = await readProfile({ profileDir: requestedProfile });
|
|
@@ -109,19 +145,66 @@ export async function runPreflight(options) {
|
|
|
109
145
|
evidenceItems.push(evidence(profileDir, 'candidate-validation', `${invalidCandidates.length} candidate value(s) were omitted because they are not a simple package@version reference.`, 'warning'));
|
|
110
146
|
}
|
|
111
147
|
const candidates = options.candidates.filter(safeCandidate);
|
|
148
|
+
const artifact = await resolveDshArtifact({ targetDsh: options.targetDsh, dshBin: options.dshBin, dshPackage: options.dshPackage, online: options.online, cacheDir: options.artifactCacheDir, packageManagerCacheDirs: options.packageManagerCacheDirs });
|
|
149
|
+
const artifactProvenance = [artifact.detail, `source=${artifact.source}`, `requested=${artifact.requestedVersion}`, ...(artifact.resolvedVersion === undefined ? [] : [`resolved=${artifact.resolvedVersion}`]), ...(artifact.integrity === undefined ? [] : [`integrity=${artifact.integrity}`]), ...(artifact.sha512 === undefined ? [] : [`sha512=${artifact.sha512}`])].join(' ');
|
|
150
|
+
evidenceItems.push(evidence(profileDir, 'target-artifact', artifactProvenance, artifact.kind === 'unavailable' || !artifact.targetVersionVerified ? 'warning' : undefined));
|
|
151
|
+
packageResolutions.push(evaluatePackageResolution({ packageName: '@deepseek-ai/dsh', requestedVersion: options.targetDsh, resolvedVersion: artifact.resolvedVersion, manifest: artifact.manifest, source: artifact.source, integrity: artifact.integrity, sha512: artifact.sha512, runtime: { dsh: options.targetDsh, node: process.versions.node, platform: process.platform } }));
|
|
152
|
+
const artifactDiagnostics = artifact.kind === 'unavailable'
|
|
153
|
+
? [{ id: 'artifact-unavailable', severity: 'warning', title: 'Target DSH artifact unavailable', evidence: [{ source: profileDir, subject: 'target-artifact', detail: artifact.detail, evidenceKind: 'static' }], explanation: 'The requested target DSH release was not available from the permitted offline artifact sources.', remediation: 'Provide --dsh-bin, --dsh-package, a local tarball, or an installed target artifact; use --online only when network resolution is explicitly intended.' }]
|
|
154
|
+
: !artifact.targetVersionVerified
|
|
155
|
+
? [{ id: 'artifact-version-unverified', severity: 'warning', title: 'Target DSH release is unverified', evidence: [{ source: artifact.path ?? profileDir, subject: 'target-artifact', detail: artifact.detail, evidenceKind: 'static' }], explanation: 'A local artifact candidate was found, but its version was not verified as the requested release.', remediation: 'Run the harness with a locally available exact release and verify its public --version output.' }]
|
|
156
|
+
: [];
|
|
157
|
+
if (artifactDiagnostics.length > 0 || artifact.kind === 'unavailable')
|
|
158
|
+
extraWarning = true;
|
|
112
159
|
evidenceItems.push(evidence(profileDir, 'target-dsh', `Rehearsal target is DSH ${options.targetDsh}.`));
|
|
113
160
|
evidenceItems.push(evidence(profileDir, 'candidate-plugins', `${candidates.length} validated candidate plugin reference(s) supplied; values are not copied into the real profile.`));
|
|
114
|
-
|
|
115
|
-
|
|
161
|
+
const candidateResolutions = [];
|
|
162
|
+
for (const candidate of candidates) {
|
|
163
|
+
evidenceItems.push(evidence(profileDir, 'candidate-accepted', `${candidate} was accepted as an exact package@version reference.`));
|
|
164
|
+
const resolution = await resolveCandidateArtifact(requestedProfile, candidate, options.online, options.artifactCacheDir, options.packageManagerCacheDirs);
|
|
165
|
+
candidateResolutions.push(resolution);
|
|
166
|
+
evidenceItems.push(evidence(profileDir, resolution.status === 'resolved' ? 'candidate-artifact-resolved' : 'candidate-declaration-recorded', resolution.detail, resolution.status === 'resolved' ? undefined : 'warning'));
|
|
167
|
+
packageResolutions.push(resolution.status === 'resolved' && resolution.artifact !== undefined
|
|
168
|
+
? evaluatePackageResolution({ packageName: resolution.artifact.name, requestedVersion: resolution.artifact.requestedVersion, resolvedVersion: resolution.artifact.installedVersion, manifest: resolution.artifact.manifest, source: resolution.artifact.source, integrity: resolution.artifact.integrity, sha512: resolution.artifact.sha512, runtime: { dsh: options.targetDsh, node: process.versions.node, platform: process.platform } })
|
|
169
|
+
: { packageName: candidateParts(candidate).name, requestedVersion: candidateParts(candidate).version, status: 'unavailable', source: 'unavailable', checks: { artifact: 'unknown' }, evidence: [{ source: profileDir, subject: candidate, detail: resolution.detail, evidenceKind: 'static', severity: 'warning' }] });
|
|
170
|
+
evidenceItems.push(evidence(profileDir, 'candidate-package-not-installed', `${candidate} was not downloaded or installed.`));
|
|
171
|
+
evidenceItems.push(evidence(profileDir, 'candidate-runtime-unverified', `${candidate} was not loaded, so runtime compatibility is unverified.`, 'warning'));
|
|
172
|
+
}
|
|
173
|
+
const nonCompatible = packageResolutions.filter((resolution) => resolution.status !== 'compatible');
|
|
174
|
+
packageResolutionOutcome = nonCompatible.some((resolution) => resolution.status === 'incompatible') ? 'fail' : nonCompatible.length > 0 ? 'warning' : 'pass';
|
|
175
|
+
packageResolutionDetail = packageResolutions.length === 0
|
|
176
|
+
? 'No target or candidate package artifact was available for package-resolution checks.'
|
|
177
|
+
: `${nonCompatible.length} of ${packageResolutions.length} package artifact resolution(s) are not proven compatible; no package-manager install or lifecycle was executed.`;
|
|
178
|
+
if (candidates.length > 0)
|
|
116
179
|
extraWarning = true;
|
|
180
|
+
evidenceItems.push(evidence(profileDir, 'network-policy', options.online ? 'Online mode was explicitly requested; exact registry artifacts may be downloaded into Doctor-owned cache only.' : 'Offline mode enforced; no network operation was attempted.'));
|
|
117
181
|
tempDirectory = await mkdtemp(join(tmpdir(), 'dsh-doctor-'));
|
|
118
|
-
await prepareIsolatedProfile(input, tempDirectory, evidenceItems);
|
|
182
|
+
await prepareIsolatedProfile(input, tempDirectory, candidates, evidenceItems);
|
|
119
183
|
evidenceItems.push(evidence(tempDirectory, 'isolated-profile', 'Only redacted, allow-listed composition metadata was copied into the temporary profile.'));
|
|
120
184
|
try {
|
|
121
185
|
const isolatedInput = await readProfile({ profileDir: tempDirectory });
|
|
122
|
-
const
|
|
186
|
+
const staticModel = runtimeForTarget(await resolveComposition(isolatedInput), options.targetDsh);
|
|
187
|
+
const dumped = artifact.kind === 'dsh-bin' && artifact.path !== undefined ? await tryDshDump(isolatedInput, { dshBin: artifact.path }) : undefined;
|
|
188
|
+
const isolatedModel = dumped === undefined ? staticModel : { ...staticModel, rows: dumped.rows, adapterDiagnostics: [...staticModel.adapterDiagnostics.filter((item) => item.id !== 'runtime-composition-unavailable'), ...dumped.diagnostics], evidenceMode: 'composed' };
|
|
123
189
|
const report = analyseComposition(isolatedModel);
|
|
124
|
-
|
|
190
|
+
const candidateDiagnostics = candidateResolutions.filter((resolution) => resolution.status === 'unavailable').map((resolution) => {
|
|
191
|
+
const candidate = resolution.candidate;
|
|
192
|
+
const { name, version } = candidateParts(candidate);
|
|
193
|
+
return {
|
|
194
|
+
id: 'candidate-artifact-unavailable', severity: 'warning', title: 'Candidate artifact was not resolved',
|
|
195
|
+
evidence: [{ source: tempDirectory ?? profileDir, subject: candidate, packageName: name, version, detail: resolution.detail, evidenceKind: 'static' }],
|
|
196
|
+
explanation: 'A package declaration is not evidence that the candidate package manifest or runtime can be loaded.',
|
|
197
|
+
remediation: 'Provide a local exact artifact or use an explicit supported online resolver, then re-run the isolated rehearsal.'
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
const packageDiagnostics = packageResolutions.filter((resolution) => resolution.status !== 'compatible').map((resolution) => ({
|
|
201
|
+
id: `package-resolution-${resolution.status}`, severity: resolution.status === 'incompatible' ? 'error' : 'warning',
|
|
202
|
+
title: `Package resolution is ${resolution.status}`,
|
|
203
|
+
evidence: [...resolution.evidence],
|
|
204
|
+
explanation: `Package metadata resolution for ${resolution.packageName}@${resolution.requestedVersion} is ${resolution.status}.`,
|
|
205
|
+
remediation: resolution.status === 'unavailable' ? 'Provide an exact local artifact or use explicit --online registry resolution.' : 'Supply the missing runtime facts or use a compatible package version.'
|
|
206
|
+
}));
|
|
207
|
+
diagnostics = [...artifactDiagnostics, ...report.diagnostics, ...candidateDiagnostics, ...packageDiagnostics];
|
|
125
208
|
const errors = diagnostics.filter((item) => item.severity === 'error').length;
|
|
126
209
|
const warnings = diagnostics.filter((item) => item.severity === 'warning').length;
|
|
127
210
|
smokeOutcome = errors > 0 ? 'fail' : warnings > 0 ? 'warning' : 'pass';
|
|
@@ -172,6 +255,10 @@ export async function runPreflight(options) {
|
|
|
172
255
|
evidence: evidenceItems,
|
|
173
256
|
diagnostics,
|
|
174
257
|
smokeTest: { outcome: smokeOutcome, detail: smokeDetail },
|
|
258
|
+
compositionSmoke: { outcome: smokeOutcome, detail: smokeDetail },
|
|
259
|
+
packageResolutionSmoke: { outcome: packageResolutionOutcome, detail: packageResolutionDetail },
|
|
260
|
+
packageResolutions,
|
|
261
|
+
runtimeSmoke: { outcome: 'not-run', detail: 'Third-party runtime execution requires an explicit supported isolation backend; the temporary directory is not a security sandbox.' },
|
|
175
262
|
...(before === undefined ? {} : { realProfileFingerprintBefore: before }),
|
|
176
263
|
...(after === undefined ? {} : { realProfileFingerprintAfter: after }),
|
|
177
264
|
...(tempDirectory === undefined ? {} : { tempDirectory })
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { lstat, readFile, realpath, stat } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
+
import { inspectBundleInventory } from './bundle-inventory.js';
|
|
4
5
|
const allowedFiles = [
|
|
5
6
|
'cordis.yml',
|
|
6
7
|
'cordis.patch.yml',
|
|
@@ -52,5 +53,19 @@ export async function readProfile({ profileDir }) {
|
|
|
52
53
|
throw error;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
+
const packageFile = files.find((file) => file.relativePath === 'package.json');
|
|
57
|
+
const inventory = packageFile === undefined
|
|
58
|
+
? { packages: [], diagnostics: [] }
|
|
59
|
+
: await inspectBundleInventory(resolvedRoot, packageFile.text);
|
|
60
|
+
return {
|
|
61
|
+
profileDir: resolvedRoot,
|
|
62
|
+
files,
|
|
63
|
+
installedPackages: inventory.packages,
|
|
64
|
+
inventoryDiagnostics: inventory.diagnostics,
|
|
65
|
+
metadataCoverage: {
|
|
66
|
+
mode: 'allow-listed-root-metadata',
|
|
67
|
+
scannedFiles: files.map((file) => file.relativePath).sort(),
|
|
68
|
+
unscannedSurfaces: ['non-declared nested plugin manifests and non-selected package metadata', 'environment files and values', 'private keys, tokens, sessions, and workspace source files']
|
|
69
|
+
}
|
|
70
|
+
};
|
|
56
71
|
}
|
package/dist/core/redaction.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { visit, parseDocument, Scalar } from 'yaml';
|
|
2
|
+
const secretProperty = /key|token|secret|password|credential|authorization|cookie|private|passphrase|session|env/i;
|
|
2
3
|
export function redact(value) {
|
|
3
4
|
const seen = new WeakMap();
|
|
4
5
|
const visit = (current) => {
|
|
@@ -23,3 +24,29 @@ export function redact(value) {
|
|
|
23
24
|
};
|
|
24
25
|
return visit(value);
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Redact YAML through its AST. In particular, this preserves custom tags
|
|
29
|
+
* such as !!js instead of round-tripping through a plain JavaScript value.
|
|
30
|
+
* Tagged values are never executed.
|
|
31
|
+
*/
|
|
32
|
+
export function redactYamlPreservingTags(text) {
|
|
33
|
+
const document = parseDocument(text, { keepSourceTokens: true });
|
|
34
|
+
visit(document, {
|
|
35
|
+
Pair: (_key, pair) => {
|
|
36
|
+
const key = pair.key instanceof Scalar ? String(pair.key.value) : String(pair.key ?? '');
|
|
37
|
+
if (!secretProperty.test(key))
|
|
38
|
+
return;
|
|
39
|
+
if (pair.value instanceof Scalar) {
|
|
40
|
+
pair.value.value = '[REDACTED]';
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// A secret collection is replaced as a scalar; no value is evaluated,
|
|
44
|
+
// and an unsupported collection shape is not silently copied.
|
|
45
|
+
pair.value = document.createNode('[REDACTED]');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
if (document.errors.length > 0)
|
|
50
|
+
throw document.errors[0];
|
|
51
|
+
return String(document);
|
|
52
|
+
}
|
package/dist/core/rules.js
CHANGED
|
@@ -64,9 +64,9 @@ function peerDiagnostics(model) {
|
|
|
64
64
|
function bundleDiagnostics(bundles) {
|
|
65
65
|
const diagnostics = [];
|
|
66
66
|
for (const bundle of bundles) {
|
|
67
|
-
if (bundle.gitRef !== undefined && bundle.gitRef.length > 0)
|
|
67
|
+
if ((bundle.gitRef !== undefined && bundle.gitRef.length > 0) || (bundle.integrity !== undefined && bundle.integrity.length > 0))
|
|
68
68
|
continue;
|
|
69
|
-
diagnostics.push(diagnostic('missing-provenance', 'warning', 'Bundle provenance is unavailable', [evidence(bundle.source, bundle.name, 'No Git ref or
|
|
69
|
+
diagnostics.push(diagnostic('missing-provenance', 'warning', 'Bundle provenance is unavailable', [evidence(bundle.source, bundle.name, 'No Git ref or registry integrity was observed.', bundle.evidenceKind, bundle.name, bundle.version)], `The origin of ${bundle.name} cannot be verified from the selected metadata.`, 'Record an immutable Git ref or registry integrity hash in the selected package metadata.'));
|
|
70
70
|
}
|
|
71
71
|
for (const [name, group] of groups(bundles.filter((bundle) => bundle.profile !== undefined), (bundle) => bundle.name)) {
|
|
72
72
|
if (new Set(group.map((bundle) => bundle.version ?? 'unknown')).size < 2)
|
|
@@ -75,22 +75,69 @@ function bundleDiagnostics(bundles) {
|
|
|
75
75
|
}
|
|
76
76
|
return diagnostics;
|
|
77
77
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
function rowKeys(row) {
|
|
79
|
+
if (row.configKeys !== undefined)
|
|
80
|
+
return new Set(row.configKeys);
|
|
81
|
+
if (row.config !== null && typeof row.config === 'object' && !Array.isArray(row.config))
|
|
82
|
+
return new Set(Object.keys(row.config));
|
|
83
|
+
return new Set();
|
|
84
|
+
}
|
|
85
|
+
function rowDiagnostics(rows) {
|
|
86
|
+
const diagnostics = [];
|
|
87
|
+
for (const [id, group] of groups(rows.filter((row) => row.id !== undefined), (row) => row.id)) {
|
|
88
|
+
const layers = groups(group, (row) => row.layer ?? row.source);
|
|
89
|
+
if (layers.size === 1 && group.length > 1) {
|
|
90
|
+
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.'));
|
|
82
91
|
continue;
|
|
83
|
-
|
|
92
|
+
}
|
|
93
|
+
if (layers.size < 2)
|
|
94
|
+
continue;
|
|
95
|
+
const ordered = [...group].sort((left, right) => (left.layerOrder ?? Number.MAX_SAFE_INTEGER) - (right.layerOrder ?? Number.MAX_SAFE_INTEGER));
|
|
96
|
+
const previous = ordered[ordered.length - 2];
|
|
97
|
+
const current = ordered[ordered.length - 1];
|
|
98
|
+
diagnostics.push(diagnostic('intentional-row-override', 'info', 'Later composition layer overrides a row', [evidence(previous.source, id, `row id ${id} is overridden by a later layer`, previous.evidenceKind, previous.name), evidence(current.source, id, `row id ${id} replaces the earlier row`, current.evidenceKind, current.name)], `The same row id occurs across layers, which is compatible with DSH patch precedence when the later layer intentionally owns the row.`, 'Confirm the resolved provenance and keep the override documented.'));
|
|
99
|
+
const missing = [...rowKeys(previous)].filter((key) => !rowKeys(current).has(key)).sort();
|
|
100
|
+
if (missing.length > 0 && (current.replacement === true || current.configKeys !== undefined)) {
|
|
101
|
+
diagnostics.push(diagnostic('row-config-replacement-risk', 'warning', 'Row replacement drops earlier config keys', [evidence(current.source, id, `replacement omits keys: ${missing.join(', ')}`, current.evidenceKind, current.name)], `The later row replacement for ${id} does not contain all keys observed in the earlier config. Secret values are not included.`, 'Copy the required non-secret config keys into the replacing row or verify that their absence is intentional.'));
|
|
102
|
+
}
|
|
84
103
|
}
|
|
85
|
-
|
|
104
|
+
return diagnostics;
|
|
105
|
+
}
|
|
106
|
+
function uiMode(claim) {
|
|
107
|
+
if (claim.mode !== undefined)
|
|
108
|
+
return claim.mode;
|
|
109
|
+
if (claim.kind === 'sidebar')
|
|
110
|
+
return 'list-contribution';
|
|
111
|
+
if (claim.kind === 'web-route')
|
|
112
|
+
return 'exact-route';
|
|
113
|
+
return 'single-owner';
|
|
114
|
+
}
|
|
115
|
+
function uiDiagnostics(claims) {
|
|
116
|
+
const diagnostics = [];
|
|
117
|
+
for (const [claim, values] of groups(claims, (value) => `${uiMode(value)}:${value.kind}:${value.value}`)) {
|
|
86
118
|
if (values.length < 2)
|
|
87
119
|
continue;
|
|
88
|
-
|
|
120
|
+
const mode = uiMode(values[0]);
|
|
121
|
+
if (mode === 'list-contribution' && new Set(values.map((value) => value.contributionId ?? value.packageName ?? value.source)).size > 1)
|
|
122
|
+
continue;
|
|
123
|
+
const severity = mode === 'prefix-route' ? 'warning' : 'error';
|
|
124
|
+
const id = mode === 'exact-route' ? 'ui-exact-route-conflict' : mode === 'fallback-owner' ? 'ui-fallback-owner-conflict' : 'ui-ownership-conflict';
|
|
125
|
+
diagnostics.push(diagnostic(id, severity, mode === 'exact-route' ? 'Exact web route has multiple owners' : 'UI ownership claim conflicts', values.map((value) => evidence(value.source, claim, `${value.kind} ${value.value}`, value.evidenceKind, value.packageName, value.version)), mode === 'prefix-route' ? `Multiple prefix route claims may shadow one another; ownership is not proven.` : `Multiple concrete declarations claim the same ${claim} surface.`, 'Assign one owner, or provide distinct list contribution ids where the public UI API allows multiple contributors.'));
|
|
126
|
+
}
|
|
127
|
+
return diagnostics;
|
|
128
|
+
}
|
|
129
|
+
export function analyseComposition(model) {
|
|
130
|
+
const diagnostics = [...model.adapterDiagnostics];
|
|
131
|
+
const requestedMode = model.evidenceMode ?? 'static';
|
|
132
|
+
const runtimeObserved = requestedMode === 'runtime-observed' && model.runtimeObservation?.observed === true;
|
|
133
|
+
if (requestedMode === 'runtime-observed' && !runtimeObserved) {
|
|
134
|
+
diagnostics.push(diagnostic('runtime-observation-unavailable', 'warning', 'Runtime observation is unavailable', [], 'The model requested runtime-observed evidence without a successful isolated runtime observation.', 'Keep this finding composed or static until a supported isolation backend returns observed runtime facts.'));
|
|
89
135
|
}
|
|
136
|
+
diagnostics.push(...rowDiagnostics(model.rows), ...uiDiagnostics(model.uiClaims ?? []));
|
|
90
137
|
for (const [hook, values] of groups((model.hooks ?? []).filter((value) => value.hook === 'tools/pre-execute' || value.hook === 'tools/execute' || value.hook === 'tools/post-execute'), (value) => value.hook)) {
|
|
91
138
|
if (values.length < 2)
|
|
92
139
|
continue;
|
|
93
|
-
diagnostics.push(diagnostic('hook-order-risk', 'warning', 'Multiple tool waterfall registrations', values.map((value) => evidence(value.source, hook, `registered ${hook}`, value.evidenceKind, value.packageName, value.version)), `Multiple listeners register ${hook};
|
|
140
|
+
diagnostics.push(diagnostic('hook-order-risk', 'warning', 'Multiple tool waterfall registrations', values.map((value) => evidence(value.source, hook, `registered ${hook}`, value.evidenceKind, value.packageName, value.version)), `Multiple listeners register ${hook}; runtime listener order is unverified.`, 'Declare explicit ordering where the public DSH API supports it, then verify the resolved hook order in an isolated fixture.'));
|
|
94
141
|
}
|
|
95
142
|
for (const [path, writes] of groups(model.patchWrites ?? [], (write) => write.path)) {
|
|
96
143
|
if (writes.length < 2)
|
|
@@ -101,10 +148,15 @@ export function analyseComposition(model) {
|
|
|
101
148
|
const platform = model.runtime?.platform;
|
|
102
149
|
for (const requirement of model.platforms ?? []) {
|
|
103
150
|
const item = evidence(requirement.source, requirement.packageName, `supports ${requirement.supported.join(', ')}; observed ${platform ?? 'unknown'}`, requirement.evidenceKind, requirement.packageName);
|
|
104
|
-
|
|
151
|
+
const excluded = requirement.supported.includes(`!${platform ?? ''}`);
|
|
152
|
+
const positive = requirement.supported.filter((value) => !value.startsWith('!'));
|
|
153
|
+
const supported = platform !== undefined && !excluded && (positive.length === 0 || positive.includes(platform));
|
|
154
|
+
if (!supported) {
|
|
105
155
|
diagnostics.push(diagnostic('platform-mismatch', 'warning', 'Declared platform does not include selected runtime', [item], platform === undefined ? `No runtime platform was supplied for ${requirement.packageName}.` : `${requirement.packageName} declares ${requirement.supported.join(', ')}, not ${platform}.`, 'Use a supported platform or verify an intentional override in an isolated fixture.'));
|
|
106
156
|
}
|
|
107
157
|
}
|
|
108
158
|
diagnostics.sort((left, right) => left.id.localeCompare(right.id) || left.title.localeCompare(right.title) || left.evidence[0]?.source.localeCompare(right.evidence[0]?.source ?? '') || 0);
|
|
109
|
-
|
|
159
|
+
const evidenceMode = runtimeObserved ? 'runtime-observed' : requestedMode === 'runtime-observed' ? 'composed' : requestedMode;
|
|
160
|
+
const unverifiedFindings = diagnostics.filter((item) => item.id === 'runtime-composition-unavailable' || item.evidence.some((entry) => entry.evidenceKind === 'static')).map((item) => item.id);
|
|
161
|
+
return { schemaVersion: 1, evidenceSchemaVersion: 2, generatedAt: new Date().toISOString(), profileDir: model.profileDir, evidenceMode, runtimeObserved, ...(model.metadataCoverage === undefined ? {} : { metadataCoverage: model.metadataCoverage }), unverifiedFindings, diagnostics };
|
|
110
162
|
}
|
package/dist/core/snapshot.js
CHANGED
|
@@ -1,59 +1,73 @@
|
|
|
1
|
+
import { basename, isAbsolute, relative } from 'node:path';
|
|
2
|
+
import { analyseComposition } from './rules.js';
|
|
1
3
|
import { resolveComposition } from './composition-adapter.js';
|
|
2
|
-
function isRecord(value) {
|
|
3
|
-
|
|
4
|
+
function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
5
|
+
function normalizedSource(source, profileDir) {
|
|
6
|
+
if (!isAbsolute(source))
|
|
7
|
+
return source.replaceAll('\\', '/');
|
|
8
|
+
const value = relative(profileDir, source).replaceAll('\\', '/');
|
|
9
|
+
return value.startsWith('../') || value === '..' ? `<EXTERNAL>/${basename(source)}` : `<PROFILE>/${value}`;
|
|
4
10
|
}
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
function sortJson(values) { return [...values].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); }
|
|
12
|
+
function packageSnapshot(fact) {
|
|
13
|
+
return {
|
|
14
|
+
name: fact.name,
|
|
15
|
+
...(fact.requestedSpec === undefined ? {} : { requestedSpec: fact.requestedSpec }),
|
|
16
|
+
...(fact.installedVersion === undefined ? {} : { installedVersion: fact.installedVersion }),
|
|
17
|
+
packageJsonSource: `<BUNDLE:${fact.name}>/package.json`,
|
|
18
|
+
...(fact.bundlePatch === undefined ? {} : { bundlePatch: `<BUNDLE:${fact.name}>/${basename(fact.bundlePatch)}` }),
|
|
19
|
+
...(fact.repository === undefined ? {} : { repository: fact.repository }),
|
|
20
|
+
...(fact.gitRef === undefined ? {} : { gitRef: fact.gitRef }),
|
|
21
|
+
...(fact.integrity === undefined ? {} : { integrity: fact.integrity }),
|
|
22
|
+
...(fact.platform === undefined ? {} : { platform: [...fact.platform].sort() }),
|
|
23
|
+
...(fact.peerDsh === undefined ? {} : { peerDsh: fact.peerDsh }),
|
|
24
|
+
...(fact.peerCordis === undefined ? {} : { peerCordis: fact.peerCordis }),
|
|
25
|
+
...(fact.engineNode === undefined ? {} : { engineNode: fact.engineNode })
|
|
26
|
+
};
|
|
9
27
|
}
|
|
10
|
-
function
|
|
11
|
-
return
|
|
28
|
+
function packageFromBundle(bundle, profileDir) {
|
|
29
|
+
return { name: bundle.name, ...(bundle.requestedSpec === undefined ? {} : { requestedSpec: bundle.requestedSpec }), ...(bundle.version === undefined ? {} : { installedVersion: bundle.version }), packageJsonSource: normalizedSource(bundle.source, profileDir), ...(bundle.repository === undefined ? {} : { repository: bundle.repository }), ...(bundle.gitRef === undefined ? {} : { gitRef: bundle.gitRef }), ...(bundle.integrity === undefined ? {} : { integrity: bundle.integrity }) };
|
|
12
30
|
}
|
|
13
|
-
function
|
|
14
|
-
const file = input
|
|
31
|
+
function lockKind(input) {
|
|
32
|
+
const file = input?.files.find((item) => ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'].includes(item.relativePath));
|
|
15
33
|
if (file === undefined)
|
|
16
|
-
return {
|
|
34
|
+
return {};
|
|
35
|
+
return { kind: file.relativePath === 'pnpm-lock.yaml' ? 'pnpm' : file.relativePath === 'yarn.lock' ? 'yarn' : 'npm', lockfileHash: file.sha256 };
|
|
36
|
+
}
|
|
37
|
+
function snapshotFromModel(model, input) {
|
|
38
|
+
const profileDir = model.profileDir;
|
|
39
|
+
const report = analyseComposition(model);
|
|
40
|
+
const manifest = input?.files.find((candidate) => candidate.relativePath === 'package.json');
|
|
41
|
+
let profileName;
|
|
17
42
|
try {
|
|
18
|
-
|
|
19
|
-
const packageName = typeof manifest.name === 'string' ? manifest.name : undefined;
|
|
20
|
-
const plugins = [...stringMap(manifest.dependencies), ...stringMap(manifest.devDependencies)]
|
|
21
|
-
.map(([name, version]) => ({ name, version, source: file.relativePath }))
|
|
22
|
-
.sort((left, right) => left.name.localeCompare(right.name) || left.version.localeCompare(right.version));
|
|
23
|
-
const peers = packageName === undefined ? [] : [{
|
|
24
|
-
packageName,
|
|
25
|
-
source: file.relativePath,
|
|
26
|
-
...Object.fromEntries(stringMap(manifest.peerDependencies).filter(([name]) => name === '@deepseek-ai/dsh' || name === '@deepseek-ai/cordis' || name === 'node').map(([name, range]) => [name === '@deepseek-ai/dsh' ? 'dsh' : name === '@deepseek-ai/cordis' ? 'cordis' : 'node', range]))
|
|
27
|
-
}];
|
|
28
|
-
const platforms = packageName === undefined || !Array.isArray(manifest.os) ? [] : [{ packageName, source: file.relativePath, supported: manifest.os.filter((platform) => typeof platform === 'string').sort() }];
|
|
29
|
-
return { ...(packageName === undefined ? {} : { packageName }), plugins, peers, platforms };
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return { plugins: [], peers: [], platforms: [] };
|
|
43
|
+
profileName = manifest === undefined ? undefined : JSON.parse(manifest.text).name;
|
|
33
44
|
}
|
|
34
|
-
}
|
|
35
|
-
|
|
45
|
+
catch { /* hash-only metadata is still valid */ }
|
|
46
|
+
const packages = model.installedPackages?.map(packageSnapshot) ?? model.bundles?.map((bundle) => packageFromBundle(bundle, profileDir)) ?? [];
|
|
47
|
+
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]));
|
|
36
48
|
return {
|
|
37
|
-
schemaVersion:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
schemaVersion: 2,
|
|
50
|
+
doctorVersion: '0.1.3',
|
|
51
|
+
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 },
|
|
52
|
+
profile: { ...(profileName === undefined ? {} : { name: profileName }), ...(manifest === undefined ? {} : { manifestHash: manifest.sha256 }) },
|
|
53
|
+
packages: sortJson(packages),
|
|
54
|
+
rows: model.rows.map((row) => ({ ...(row.id === undefined ? {} : { id: row.id }), ...(row.name === undefined ? {} : { name: row.name }), source: normalizedSource(row.source, profileDir), ...(row.layer === undefined ? {} : { layer: row.layer }), ...(row.configKeys === undefined ? {} : { configKeys: [...row.configKeys].sort() }), ...(row.replacement === true ? { replacement: true } : {}) })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
|
|
55
|
+
hooks: sortJson((model.hooks ?? []).map(({ hook, source, packageName, version }) => ({ hook, source: normalizedSource(source, profileDir), ...(packageName === undefined ? {} : { packageName }), ...(version === undefined ? {} : { version }) }))),
|
|
56
|
+
uiClaims: sortJson((model.uiClaims ?? []).map(({ kind, value, source, packageName, version, mode, contributionId }) => ({ kind, value, source: normalizedSource(source, profileDir), ...(packageName === undefined ? {} : { packageName }), ...(version === undefined ? {} : { version }), ...(mode === undefined ? {} : { mode }), ...(contributionId === undefined ? {} : { contributionId }) }))),
|
|
57
|
+
conflicts: sortJson(report.diagnostics.map((item) => ({ id: item.id, severity: item.severity, subjects: item.evidence.map((entry) => `${normalizedSource(entry.source, profileDir)}:${entry.subject ?? ''}`).sort() }))),
|
|
58
|
+
packageManager: lockKind(input),
|
|
46
59
|
hashes
|
|
47
60
|
};
|
|
48
61
|
}
|
|
49
|
-
/** Creates a structural, redacted snapshot from already allow-listed profile input or a resolved model. */
|
|
50
62
|
export async function createSnapshot(input) {
|
|
51
|
-
if ('files' in input)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
if ('files' in input)
|
|
64
|
+
return snapshotFromModel(await resolveComposition(input), input);
|
|
65
|
+
return snapshotFromModel(input);
|
|
66
|
+
}
|
|
67
|
+
export function isSnapshotV2(value) { return isRecord(value) && value.schemaVersion === 2 && Array.isArray(value.packages) && isRecord(value.profile); }
|
|
68
|
+
export function isSnapshotV1(value) { return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.plugins); }
|
|
69
|
+
export function legacyPlugins(snapshot) {
|
|
70
|
+
if (isSnapshotV1(snapshot))
|
|
71
|
+
return snapshot.plugins;
|
|
72
|
+
return snapshot.packages.map((item) => ({ name: item.name, ...(item.installedVersion === undefined ? {} : { version: item.installedVersion }), source: item.packageJsonSource }));
|
|
59
73
|
}
|
package/dist/plugin/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { lstat, readFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import z from '@deepseek-ai/schemastery';
|
|
4
|
+
import { defaultReportDirectory, resolveReportDirectory } from '../reports/location.js';
|
|
4
5
|
export const name = 'dsh-composition-doctor';
|
|
5
6
|
/** Public Cordis service dependency supplied by dsh-host-webserver. */
|
|
6
7
|
export const inject = ['webServer'];
|
|
7
|
-
export const Config = z.object({ reportDir: z.string() });
|
|
8
|
+
export const Config = z.object({ reportDir: z.string().default(defaultReportDirectory) });
|
|
8
9
|
export const latestReportPath = '/dsh-composition-doctor/reports/latest';
|
|
10
|
+
const maxReportBytes = 2 * 1024 * 1024;
|
|
9
11
|
function reportDirectory(config) {
|
|
10
|
-
return
|
|
12
|
+
return resolveReportDirectory(config.reportDir);
|
|
11
13
|
}
|
|
12
14
|
function send(response, status, body) {
|
|
13
15
|
response.statusCode = status;
|
|
@@ -33,8 +35,12 @@ async function latestReport(config) {
|
|
|
33
35
|
const fileStat = await lstat(candidate);
|
|
34
36
|
if (!fileStat.isFile())
|
|
35
37
|
continue;
|
|
38
|
+
if (fileStat.size > maxReportBytes)
|
|
39
|
+
continue;
|
|
36
40
|
const text = await readFile(candidate, 'utf8');
|
|
37
|
-
JSON.parse(text);
|
|
41
|
+
const value = JSON.parse(text);
|
|
42
|
+
if (!isAnalysisReport(value))
|
|
43
|
+
continue;
|
|
38
44
|
return text.endsWith('\n') ? text : `${text}\n`;
|
|
39
45
|
}
|
|
40
46
|
catch {
|
|
@@ -44,6 +50,13 @@ async function latestReport(config) {
|
|
|
44
50
|
}
|
|
45
51
|
return undefined;
|
|
46
52
|
}
|
|
53
|
+
function isAnalysisReport(value) {
|
|
54
|
+
if (value === null || typeof value !== 'object')
|
|
55
|
+
return false;
|
|
56
|
+
const report = value;
|
|
57
|
+
const legacy = report.evidenceSchemaVersion === undefined && report.evidenceMode === 'resolved';
|
|
58
|
+
return report.schemaVersion === 1 && (report.evidenceSchemaVersion === 2 || legacy) && Array.isArray(report.diagnostics) && (report.evidenceMode === 'static' || report.evidenceMode === 'composed' || report.evidenceMode === 'runtime-observed' || report.evidenceMode === 'mixed' || legacy) && typeof report.generatedAt === 'string' && (report.runtimeObserved === undefined || typeof report.runtimeObserved === 'boolean');
|
|
59
|
+
}
|
|
47
60
|
function route(config) {
|
|
48
61
|
return {
|
|
49
62
|
kind: 'exact',
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Relative to the DSH host process working directory. This directory is owned
|
|
4
|
+
* by the doctor plugin and is deliberately separate from a scanned profile.
|
|
5
|
+
*/
|
|
6
|
+
export const defaultReportDirectory = '.dsh-composition-doctor/reports';
|
|
7
|
+
export function resolveReportDirectory(reportDir = defaultReportDirectory) {
|
|
8
|
+
return resolve(reportDir);
|
|
9
|
+
}
|
package/dist/reports/markdown.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
function formatEvidence(item) {
|
|
2
2
|
const subject = item.subject === undefined ? '' : ` — ${item.subject}`;
|
|
3
3
|
const packageLabel = item.packageName === undefined ? '' : ` (${item.packageName}${item.version === undefined ? '' : `@${item.version}`})`;
|
|
4
|
-
return `- \`${item.source}\`${subject}${packageLabel}: ${item.detail}`;
|
|
4
|
+
return `- [${item.evidenceKind}] \`${item.source}\`${subject}${packageLabel}: ${item.detail}`;
|
|
5
5
|
}
|
|
6
6
|
function formatDiagnostic(item) {
|
|
7
7
|
const evidence = item.evidence.length === 0 ? '- No concrete source evidence was supplied.' : item.evidence.map(formatEvidence).join('\n');
|
|
@@ -10,5 +10,6 @@ function formatDiagnostic(item) {
|
|
|
10
10
|
export function renderMarkdown(report) {
|
|
11
11
|
const summary = ['error', 'warning', 'info'].map((severity) => `${severity}: ${report.diagnostics.filter((item) => item.severity === severity).length}`).join(', ');
|
|
12
12
|
const body = report.diagnostics.length === 0 ? 'No diagnostics were produced.' : report.diagnostics.map(formatDiagnostic).join('\n\n');
|
|
13
|
-
|
|
13
|
+
const coverage = report.metadataCoverage === undefined ? '' : `\nMetadata coverage: ${report.metadataCoverage.mode}; unscanned: ${report.metadataCoverage.unscannedSurfaces.join('; ')}\n`;
|
|
14
|
+
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`;
|
|
14
15
|
}
|