dsh-composition-doctor 0.1.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.
@@ -0,0 +1,69 @@
1
+ function formatChange(change) {
2
+ const before = change.before === undefined ? '' : ` before=${JSON.stringify(change.before)}`;
3
+ const after = change.after === undefined ? '' : ` after=${JSON.stringify(change.after)}`;
4
+ return `- **${change.kind}** \`${change.key}\`${before}${after}`;
5
+ }
6
+ /** Renders the semantic diff without hiding the evidence in a summary-only line. */
7
+ export function renderSnapshotDiffMarkdown(diff) {
8
+ const sections = [
9
+ ['Plugins', diff.pluginChanges],
10
+ ['Cordis rows', diff.rowChanges],
11
+ ['Tool hooks', diff.hookChanges],
12
+ ['UI claims', diff.uiConflictChanges],
13
+ ['Peer requirements', diff.peerChanges],
14
+ ['Platform requirements', diff.platformChanges]
15
+ ];
16
+ const body = sections.map(([title, values]) => `## ${title}\n\n${values.length === 0 ? 'No changes.' : values.map(formatChange).join('\n')}`).join('\n\n');
17
+ const riskItems = diff.upgradeRisk.items.length === 0 ? '- None.' : diff.upgradeRisk.items.map((item) => `- ${item}`).join('\n');
18
+ return `# DSH Composition Doctor Snapshot Diff\n\n${diff.upgradeRisk.summary}\n\n## Upgrade risk items\n\n${riskItems}\n\n${body}\n`;
19
+ }
20
+ function compareVersions(left, right) {
21
+ if (left === undefined || right === undefined)
22
+ return undefined;
23
+ const parse = (value) => /^(\d+)\.(\d+)\.(\d+)/.exec(value)?.slice(1).map(Number);
24
+ const a = parse(left);
25
+ const b = parse(right);
26
+ if (a === undefined || b === undefined)
27
+ return undefined;
28
+ for (let index = 0; index < 3; index += 1)
29
+ if (a[index] !== b[index])
30
+ return a[index] < b[index] ? -1 : 1;
31
+ return 0;
32
+ }
33
+ function changes(before, after, key) {
34
+ const previous = new Map(before.map((item) => [key(item), item]));
35
+ const next = new Map(after.map((item) => [key(item), item]));
36
+ const result = [];
37
+ for (const itemKey of [...new Set([...previous.keys(), ...next.keys()])].sort()) {
38
+ const left = previous.get(itemKey);
39
+ const right = next.get(itemKey);
40
+ if (left === undefined)
41
+ result.push({ kind: 'added', key: itemKey, after: right });
42
+ else if (right === undefined)
43
+ result.push({ kind: 'removed', key: itemKey, before: left });
44
+ else if (JSON.stringify(left) !== JSON.stringify(right))
45
+ result.push({ kind: 'changed', key: itemKey, before: left, after: right });
46
+ }
47
+ return result;
48
+ }
49
+ export function diffSnapshots(before, after) {
50
+ const pluginChanges = changes(before.plugins, after.plugins, (item) => item.name).map((change) => {
51
+ if (change.kind !== 'changed')
52
+ return { ...change, name: change.key };
53
+ const comparison = compareVersions(change.before?.version, change.after?.version);
54
+ return { ...change, name: change.key, kind: comparison === undefined ? 'changed' : comparison < 0 ? 'upgraded' : comparison > 0 ? 'downgraded' : 'changed' };
55
+ });
56
+ const rowChanges = changes(before.rows, after.rows, (item) => `${item.source}:${item.id ?? item.name ?? ''}`);
57
+ const hookChanges = changes(before.hooks, after.hooks, (item) => `${item.source}:${item.hook}:${item.packageName ?? ''}`);
58
+ const uiConflictChanges = changes(before.uiClaims, after.uiClaims, (item) => `${item.kind}:${item.value}:${item.source}`);
59
+ const peerChanges = changes(before.peers, after.peers, (item) => `${item.source}:${item.packageName}`);
60
+ const platformChanges = changes(before.platforms, after.platforms, (item) => `${item.source}:${item.packageName}`);
61
+ const items = pluginChanges.filter((change) => change.kind === 'downgraded' || change.kind === 'upgraded').map((change) => `${change.kind} plugin ${change.name}`);
62
+ if (hookChanges.length > 0)
63
+ items.push('hook registrations changed; verify runtime ordering in an isolated fixture');
64
+ if (uiConflictChanges.length > 0)
65
+ items.push('UI claims changed; verify ownership conflicts before upgrade');
66
+ if (peerChanges.length > 0 || platformChanges.length > 0)
67
+ items.push('peer or platform requirements changed; verify supported runtime facts');
68
+ return { pluginChanges, rowChanges, hookChanges, uiConflictChanges, peerChanges, platformChanges, upgradeRisk: { summary: items.length === 0 ? 'No upgrade risks detected from snapshot metadata.' : `Upgrade risk: ${items.join('; ')}. No migration was executed.`, items } };
69
+ }
@@ -0,0 +1,179 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { parse, stringify } from 'yaml';
6
+ import { resolveComposition } from './composition-adapter.js';
7
+ import { readProfile } from './profile-reader.js';
8
+ import { redact } from './redaction.js';
9
+ import { analyseComposition } from './rules.js';
10
+ const yamlFiles = new Set(['cordis.yml', 'cordis.patch.yml']);
11
+ const lockFiles = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']);
12
+ const packageNamePattern = /^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/i;
13
+ const versionPattern = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?$/i;
14
+ function evidence(source, subject, detail, severity) {
15
+ return { source, subject, detail, ...(severity === undefined ? {} : { severity }) };
16
+ }
17
+ /** A digest of the allow-listed metadata surface; file contents are never returned. */
18
+ export function profileFingerprint(input) {
19
+ const surface = input.files
20
+ .map((file) => `${file.relativePath}\u0000${file.sha256}`)
21
+ .sort()
22
+ .join('\u0001');
23
+ return createHash('sha256').update(surface, 'utf8').digest('hex');
24
+ }
25
+ function safeCandidate(value) {
26
+ const at = value.lastIndexOf('@');
27
+ if (at <= 0 || at === value.length - 1)
28
+ return false;
29
+ const name = value.slice(0, at);
30
+ const version = value.slice(at + 1);
31
+ return packageNamePattern.test(name) && versionPattern.test(version);
32
+ }
33
+ function safeMetadataText(relativePath, text) {
34
+ try {
35
+ if (relativePath === 'package.json') {
36
+ return `${JSON.stringify(redact(JSON.parse(text)), null, 2)}\n`;
37
+ }
38
+ if (yamlFiles.has(relativePath)) {
39
+ return stringify(redact(parse(text)));
40
+ }
41
+ }
42
+ catch {
43
+ // Invalid metadata is reported by the composition adapter; it is not copied
44
+ // because copying an unparseable source could carry an undiscovered secret.
45
+ }
46
+ return undefined;
47
+ }
48
+ async function prepareIsolatedProfile(input, directory, evidenceItems) {
49
+ for (const file of input.files) {
50
+ if (lockFiles.has(file.relativePath)) {
51
+ evidenceItems.push(evidence(file.relativePath, 'lockfile-not-copied', 'Lockfile content is excluded from the rehearsal directory; its source hash remains available in snapshots.'));
52
+ continue;
53
+ }
54
+ const safeText = safeMetadataText(file.relativePath, file.text);
55
+ if (safeText === undefined) {
56
+ evidenceItems.push(evidence(file.relativePath, 'metadata-not-copied', 'Metadata was not copied because it could not be safely parsed.'));
57
+ continue;
58
+ }
59
+ await writeFile(join(directory, file.relativePath), safeText, { encoding: 'utf8', flag: 'wx' });
60
+ }
61
+ }
62
+ function runtimeForTarget(model, targetDsh) {
63
+ const runtime = {
64
+ ...(model.runtime ?? {}),
65
+ dsh: targetDsh,
66
+ node: model.runtime?.node ?? process.versions.node,
67
+ platform: model.runtime?.platform ?? process.platform
68
+ };
69
+ return { ...model, runtime };
70
+ }
71
+ function outcomeFor(smoke, diagnostics, extraWarning) {
72
+ if (smoke === 'fail' || diagnostics.some((item) => item.severity === 'error'))
73
+ return 'fail';
74
+ if (smoke === 'warning' || extraWarning || diagnostics.some((item) => item.severity === 'warning'))
75
+ return 'warning';
76
+ return 'pass';
77
+ }
78
+ /**
79
+ * Builds a redacted rehearsal profile in the OS temp directory and performs
80
+ * parse/adapter analysis there. It never invokes a package manager or a
81
+ * third-party lifecycle; --allow-build is recorded as an explicit plan gate.
82
+ */
83
+ export async function runPreflight(options) {
84
+ const requestedProfile = resolve(options.profileDir);
85
+ const evidenceItems = [];
86
+ let before;
87
+ let after;
88
+ let tempDirectory;
89
+ let smokeOutcome = 'pass';
90
+ let smokeDetail = 'No isolated smoke test was run.';
91
+ let diagnostics = [];
92
+ let profileDir = requestedProfile;
93
+ let extraWarning = false;
94
+ try {
95
+ if (!versionPattern.test(options.targetDsh)) {
96
+ evidenceItems.push(evidence(requestedProfile, 'target-dsh-invalid', 'Target DSH version must be an explicit semantic version.', 'error'));
97
+ return {
98
+ 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.' }
100
+ };
101
+ }
102
+ const input = await readProfile({ profileDir: requestedProfile });
103
+ profileDir = input.profileDir;
104
+ before = profileFingerprint(input);
105
+ evidenceItems.push(evidence(profileDir, 'profile-fingerprint-before', `Allow-listed metadata fingerprint recorded: ${before}`));
106
+ const invalidCandidates = options.candidates.filter((candidate) => !safeCandidate(candidate));
107
+ if (invalidCandidates.length > 0) {
108
+ extraWarning = true;
109
+ evidenceItems.push(evidence(profileDir, 'candidate-validation', `${invalidCandidates.length} candidate value(s) were omitted because they are not a simple package@version reference.`, 'warning'));
110
+ }
111
+ const candidates = options.candidates.filter(safeCandidate);
112
+ evidenceItems.push(evidence(profileDir, 'target-dsh', `Rehearsal target is DSH ${options.targetDsh}.`));
113
+ evidenceItems.push(evidence(profileDir, 'candidate-plugins', `${candidates.length} validated candidate plugin reference(s) supplied; values are not copied into the real profile.`));
114
+ evidenceItems.push(evidence(profileDir, 'network-policy', options.online ? 'Online mode was requested, but this adapter performs no network operation.' : 'Offline mode enforced; no network operation was attempted.'));
115
+ if (options.online)
116
+ extraWarning = true;
117
+ tempDirectory = await mkdtemp(join(tmpdir(), 'dsh-doctor-'));
118
+ await prepareIsolatedProfile(input, tempDirectory, evidenceItems);
119
+ evidenceItems.push(evidence(tempDirectory, 'isolated-profile', 'Only redacted, allow-listed composition metadata was copied into the temporary profile.'));
120
+ try {
121
+ const isolatedInput = await readProfile({ profileDir: tempDirectory });
122
+ const isolatedModel = runtimeForTarget(await resolveComposition(isolatedInput), options.targetDsh);
123
+ const report = analyseComposition(isolatedModel);
124
+ diagnostics = report.diagnostics;
125
+ const errors = diagnostics.filter((item) => item.severity === 'error').length;
126
+ const warnings = diagnostics.filter((item) => item.severity === 'warning').length;
127
+ smokeOutcome = errors > 0 ? 'fail' : warnings > 0 ? 'warning' : 'pass';
128
+ smokeDetail = `Static composition parse and public-adapter resolution completed in isolation (${errors} error(s), ${warnings} warning(s)).`;
129
+ evidenceItems.push(evidence(tempDirectory, 'smoke-test', smokeDetail, smokeOutcome === 'fail' ? 'error' : smokeOutcome === 'warning' ? 'warning' : undefined));
130
+ }
131
+ catch (error) {
132
+ smokeOutcome = 'fail';
133
+ smokeDetail = error instanceof Error ? error.message : String(error);
134
+ evidenceItems.push(evidence(tempDirectory, 'smoke-test', `Isolated composition smoke test failed: ${smokeDetail}`, 'error'));
135
+ }
136
+ evidenceItems.push(evidence(profileDir, 'build-plan', options.allowBuild ? 'Build execution was explicitly allowed, but no public build runner is configured; no third-party lifecycle was invoked.' : 'Build candidates would be rehearsed here only after explicit --allow-build.'));
137
+ evidenceItems.push(evidence(profileDir, 'build-not-executed', 'No package-manager install or build script was executed by this preflight.', options.allowBuild ? 'warning' : undefined));
138
+ if (options.allowBuild)
139
+ extraWarning = true;
140
+ }
141
+ catch (error) {
142
+ smokeOutcome = 'fail';
143
+ smokeDetail = error instanceof Error ? error.message : String(error);
144
+ evidenceItems.push(evidence(requestedProfile, 'preflight-error', smokeDetail, 'error'));
145
+ }
146
+ finally {
147
+ try {
148
+ const afterInput = await readProfile({ profileDir: requestedProfile });
149
+ after = profileFingerprint(afterInput);
150
+ const unchanged = before === undefined || before === after;
151
+ evidenceItems.push(evidence(profileDir, 'profile-unchanged', unchanged ? `Allow-listed metadata fingerprint is unchanged: ${after}` : `Allow-listed metadata fingerprint changed from ${before} to ${after}`, unchanged ? undefined : 'error'));
152
+ if (!unchanged)
153
+ smokeOutcome = 'fail';
154
+ }
155
+ catch (error) {
156
+ smokeOutcome = 'fail';
157
+ evidenceItems.push(evidence(requestedProfile, 'profile-fingerprint-after', `Could not verify the selected profile after rehearsal: ${error instanceof Error ? error.message : String(error)}`, 'error'));
158
+ }
159
+ if (tempDirectory !== undefined && !options.keepTemp) {
160
+ await rm(tempDirectory, { recursive: true, force: true });
161
+ evidenceItems.push(evidence(tempDirectory, 'temp-cleanup', 'Temporary rehearsal directory was removed.'));
162
+ tempDirectory = undefined;
163
+ }
164
+ }
165
+ const outcome = outcomeFor(smokeOutcome, diagnostics, extraWarning);
166
+ return {
167
+ schemaVersion: 1,
168
+ outcome,
169
+ profileDir,
170
+ targetDsh: options.targetDsh,
171
+ candidates: options.candidates.filter(safeCandidate),
172
+ evidence: evidenceItems,
173
+ diagnostics,
174
+ smokeTest: { outcome: smokeOutcome, detail: smokeDetail },
175
+ ...(before === undefined ? {} : { realProfileFingerprintBefore: before }),
176
+ ...(after === undefined ? {} : { realProfileFingerprintAfter: after }),
177
+ ...(tempDirectory === undefined ? {} : { tempDirectory })
178
+ };
179
+ }
@@ -0,0 +1,56 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath, stat } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ const allowedFiles = [
5
+ 'cordis.yml',
6
+ 'cordis.patch.yml',
7
+ 'package.json',
8
+ 'pnpm-lock.yaml',
9
+ 'package-lock.json',
10
+ 'yarn.lock'
11
+ ];
12
+ export class ProfileDirectoryError extends Error {
13
+ code = 'PROFILE_DIRECTORY_INVALID';
14
+ constructor(profileDir, reason) {
15
+ super(`Profile directory ${reason}: ${profileDir}`);
16
+ this.name = 'ProfileDirectoryError';
17
+ }
18
+ }
19
+ export async function readProfile({ profileDir }) {
20
+ const requestedRoot = resolve(profileDir);
21
+ let rootStats;
22
+ try {
23
+ rootStats = await stat(requestedRoot);
24
+ }
25
+ catch (error) {
26
+ if (error.code === 'ENOENT') {
27
+ throw new ProfileDirectoryError(profileDir, 'missing');
28
+ }
29
+ throw error;
30
+ }
31
+ if (!rootStats.isDirectory()) {
32
+ throw new ProfileDirectoryError(profileDir, 'not-directory');
33
+ }
34
+ const resolvedRoot = await realpath(requestedRoot);
35
+ const files = [];
36
+ for (const relativePath of allowedFiles) {
37
+ const candidate = resolve(requestedRoot, relativePath);
38
+ try {
39
+ const candidateStats = await lstat(candidate);
40
+ if (candidateStats.isSymbolicLink() || !candidateStats.isFile())
41
+ continue;
42
+ const text = await readFile(candidate, 'utf8');
43
+ files.push({
44
+ relativePath,
45
+ sha256: createHash('sha256').update(text, 'utf8').digest('hex'),
46
+ text
47
+ });
48
+ }
49
+ catch (error) {
50
+ if (error.code === 'ENOENT')
51
+ continue;
52
+ throw error;
53
+ }
54
+ }
55
+ return { profileDir: resolvedRoot, files };
56
+ }
@@ -0,0 +1,25 @@
1
+ const secretProperty = /key|token|secret|password|credential|authorization/i;
2
+ export function redact(value) {
3
+ const seen = new WeakMap();
4
+ const visit = (current) => {
5
+ if (current === null || typeof current !== 'object')
6
+ return current;
7
+ const existing = seen.get(current);
8
+ if (existing !== undefined)
9
+ return existing;
10
+ if (Array.isArray(current)) {
11
+ const copy = [];
12
+ seen.set(current, copy);
13
+ for (const item of current)
14
+ copy.push(visit(item));
15
+ return copy;
16
+ }
17
+ const copy = {};
18
+ seen.set(current, copy);
19
+ for (const property of Object.keys(current)) {
20
+ copy[property] = secretProperty.test(property) ? '[REDACTED]' : visit(current[property]);
21
+ }
22
+ return copy;
23
+ };
24
+ return visit(value);
25
+ }
@@ -0,0 +1,110 @@
1
+ import { satisfies as semverSatisfies } from 'semver';
2
+ function evidence(source, subject, detail, evidenceKind, packageName, version) {
3
+ return { source, subject, detail, evidenceKind, ...(packageName === undefined ? {} : { packageName }), ...(version === undefined ? {} : { version }) };
4
+ }
5
+ function diagnostic(id, severity, title, items, explanation, remediation) {
6
+ return { id, severity, title, evidence: [...items], explanation, remediation };
7
+ }
8
+ function groups(values, key) {
9
+ const result = new Map();
10
+ for (const value of values) {
11
+ const groupKey = key(value);
12
+ const group = result.get(groupKey);
13
+ if (group === undefined)
14
+ result.set(groupKey, [value]);
15
+ else
16
+ group.push(value);
17
+ }
18
+ return result;
19
+ }
20
+ function parseVersion(value) {
21
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value);
22
+ return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])];
23
+ }
24
+ /**
25
+ * Evaluate a package-manager semver range without any network access. The
26
+ * includePrerelease option matters for the verified DSH preview range.
27
+ */
28
+ function satisfiesRange(version, range) {
29
+ if (parseVersion(version) === undefined || range.trim().length === 0)
30
+ return undefined;
31
+ try {
32
+ return semverSatisfies(version, range, { includePrerelease: true });
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ function patchEvidence(write) {
39
+ return evidence(write.source, write.path, `patch write to ${write.path}`, write.evidenceKind, write.packageName, write.version);
40
+ }
41
+ function peerDiagnostics(model) {
42
+ const diagnostics = [];
43
+ const runtime = model.runtime ?? {};
44
+ for (const peer of model.peerRequirements ?? []) {
45
+ for (const [fact, range] of [['dsh', peer.dsh], ['cordis', peer.cordis], ['node', peer.node]]) {
46
+ if (range === undefined)
47
+ continue;
48
+ const actual = runtime[fact];
49
+ const item = evidence(peer.source, `${peer.packageName}:${fact}`, `requires ${fact} ${range}; observed ${actual ?? 'unknown'}`, peer.evidenceKind, peer.packageName);
50
+ if (actual === undefined) {
51
+ 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
+ continue;
53
+ }
54
+ if (satisfiesRange(actual, range) === false) {
55
+ diagnostics.push(diagnostic('peer-version-mismatch', 'warning', 'Declared peer range does not match runtime fact', [item], `${peer.packageName} declares ${fact} ${range}, but the supplied runtime fact is ${actual}. Static analysis cannot prove the runtime load outcome.`, `Use a ${fact} version within ${range}, or update the package peer declaration after testing the supported combination.`));
56
+ }
57
+ else if (satisfiesRange(actual, range) === undefined) {
58
+ diagnostics.push(diagnostic('peer-range-unrecognized', 'warning', 'Peer dependency range could not be evaluated', [item], `The declared ${fact} range ${range} is not understood by the offline semver adapter, so compatibility is unproven.`, `Verify ${peer.packageName} against ${fact} ${actual} using the package manager's resolver.`));
59
+ }
60
+ }
61
+ }
62
+ return diagnostics;
63
+ }
64
+ function bundleDiagnostics(bundles) {
65
+ const diagnostics = [];
66
+ for (const bundle of bundles) {
67
+ if (bundle.gitRef !== undefined && bundle.gitRef.length > 0)
68
+ continue;
69
+ diagnostics.push(diagnostic('missing-provenance', 'warning', 'Bundle provenance is unavailable', [evidence(bundle.source, bundle.name, 'No Git ref or immutable provenance was declared.', bundle.evidenceKind, bundle.name, bundle.version)], `The origin of ${bundle.name} cannot be verified from the selected metadata.`, 'Record an immutable Git ref, registry integrity hash, or other package provenance in the selected profile metadata.'));
70
+ }
71
+ for (const [name, group] of groups(bundles.filter((bundle) => bundle.profile !== undefined), (bundle) => bundle.name)) {
72
+ if (new Set(group.map((bundle) => bundle.version ?? 'unknown')).size < 2)
73
+ continue;
74
+ diagnostics.push(diagnostic('cross-profile-bundle-drift', 'warning', 'Bundle versions drift across profiles', group.map((bundle) => evidence(bundle.source, name, `profile ${bundle.profile ?? 'unknown'} declares ${bundle.version ?? 'unknown'}`, bundle.evidenceKind, bundle.name, bundle.version)), `${name} has different declared versions across compared profile metadata.`, 'Align the intended bundle version, or keep the profiles intentionally divergent and document why.'));
75
+ }
76
+ return diagnostics;
77
+ }
78
+ export function analyseComposition(model) {
79
+ const diagnostics = [...model.adapterDiagnostics];
80
+ for (const [id, rows] of groups(model.rows.filter((row) => row.id !== undefined), (row) => row.id)) {
81
+ if (rows.length < 2)
82
+ continue;
83
+ diagnostics.push(diagnostic('duplicate-row-id', 'error', 'Duplicate composition row id', rows.map((row) => evidence(row.source, id, `row id ${id}`, row.evidenceKind, row.name)), `Multiple concrete composition rows claim the id ${id}.`, 'Give one row a unique id or remove the duplicate declaration.'));
84
+ }
85
+ for (const [claim, values] of groups(model.uiClaims ?? [], (value) => `${value.kind}:${value.value}`)) {
86
+ if (values.length < 2)
87
+ continue;
88
+ diagnostics.push(diagnostic('ui-ownership-conflict', 'error', 'Duplicate UI ownership claim', values.map((value) => evidence(value.source, claim, `${value.kind} ${value.value}`, value.evidenceKind, value.packageName, value.version)), `Multiple concrete declarations claim the same ${claim} UI surface.`, 'Assign the UI surface to one package or give each declaration a distinct slot, layout, sidebar entry, or route.'));
89
+ }
90
+ 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
+ if (values.length < 2)
92
+ 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}; static metadata does not establish their runtime order.`, 'Declare explicit ordering where the public DSH API supports it, then verify the resolved hook order in an isolated fixture.'));
94
+ }
95
+ for (const [path, writes] of groups(model.patchWrites ?? [], (write) => write.path)) {
96
+ if (writes.length < 2)
97
+ continue;
98
+ diagnostics.push(diagnostic('patch-field-overlap', 'warning', 'Patch fields overlap', writes.map(patchEvidence), `Multiple patches write ${path}; the winning value depends on resolution order not established by static evidence.`, 'Merge the writes into one owner or document and verify the intentional precedence.'));
99
+ }
100
+ diagnostics.push(...peerDiagnostics(model), ...bundleDiagnostics(model.bundles ?? []));
101
+ const platform = model.runtime?.platform;
102
+ for (const requirement of model.platforms ?? []) {
103
+ const item = evidence(requirement.source, requirement.packageName, `supports ${requirement.supported.join(', ')}; observed ${platform ?? 'unknown'}`, requirement.evidenceKind, requirement.packageName);
104
+ if (platform === undefined || !requirement.supported.includes(platform)) {
105
+ 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
+ }
107
+ }
108
+ 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
+ return { schemaVersion: 1, generatedAt: new Date().toISOString(), profileDir: model.profileDir, diagnostics };
110
+ }
@@ -0,0 +1,59 @@
1
+ import { resolveComposition } from './composition-adapter.js';
2
+ function isRecord(value) {
3
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
4
+ }
5
+ function stringMap(value) {
6
+ if (!isRecord(value))
7
+ return [];
8
+ return Object.entries(value).flatMap(([name, version]) => typeof version === 'string' ? [[name, version]] : []);
9
+ }
10
+ function sortBySource(values) {
11
+ return [...values].sort((left, right) => left.source.localeCompare(right.source) || JSON.stringify(left).localeCompare(JSON.stringify(right)));
12
+ }
13
+ function manifestSnapshot(input) {
14
+ const file = input.files.find((candidate) => candidate.relativePath === 'package.json');
15
+ if (file === undefined)
16
+ return { plugins: [], peers: [], platforms: [] };
17
+ try {
18
+ const manifest = JSON.parse(file.text);
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: [] };
33
+ }
34
+ }
35
+ function snapshotFromModel(model, profile, hashes, plugins = []) {
36
+ return {
37
+ schemaVersion: 1,
38
+ profile,
39
+ plugins: [...plugins].sort((left, right) => left.name.localeCompare(right.name) || (left.version ?? '').localeCompare(right.version ?? '')),
40
+ rows: model.rows.map(({ id, name, source }) => ({ ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }), source })).sort((left, right) => left.source.localeCompare(right.source) || (left.id ?? '').localeCompare(right.id ?? '')),
41
+ hooks: sortBySource((model.hooks ?? []).map(({ hook, source, packageName, version }) => ({ hook, source, ...(packageName === undefined ? {} : { packageName }), ...(version === undefined ? {} : { version }) }))),
42
+ uiClaims: sortBySource((model.uiClaims ?? []).map(({ kind, value, source, packageName, version }) => ({ kind, value, source, ...(packageName === undefined ? {} : { packageName }), ...(version === undefined ? {} : { version }) }))),
43
+ peers: sortBySource((model.peerRequirements ?? []).map(({ packageName, source, dsh, cordis, node }) => ({ packageName, source, ...(dsh === undefined ? {} : { dsh }), ...(cordis === undefined ? {} : { cordis }), ...(node === undefined ? {} : { node }) }))),
44
+ platforms: sortBySource((model.platforms ?? []).map(({ packageName, source, supported }) => ({ packageName, source, supported: [...supported].sort() }))),
45
+ bundles: sortBySource((model.bundles ?? []).map(({ name, version, source, gitRef, profile: bundleProfile }) => ({ name, source, ...(version === undefined ? {} : { version }), ...(gitRef === undefined ? {} : { gitRef }), ...(bundleProfile === undefined ? {} : { profile: bundleProfile }) }))),
46
+ hashes
47
+ };
48
+ }
49
+ /** Creates a structural, redacted snapshot from already allow-listed profile input or a resolved model. */
50
+ export async function createSnapshot(input) {
51
+ if ('files' in input) {
52
+ const manifest = manifestSnapshot(input);
53
+ const hashes = Object.fromEntries(input.files
54
+ .filter((file) => file.relativePath === 'package.json' || file.relativePath === 'pnpm-lock.yaml' || file.relativePath === 'package-lock.json' || file.relativePath === 'yarn.lock')
55
+ .map((file) => [file.relativePath, file.sha256]));
56
+ return snapshotFromModel(await resolveComposition(input), { path: input.profileDir, ...(manifest.packageName === undefined ? {} : { packageName: manifest.packageName }) }, hashes, manifest.plugins);
57
+ }
58
+ return snapshotFromModel(input, { path: input.profileDir }, {}, input.bundles?.map(({ name, version, source }) => ({ name, source, ...(version === undefined ? {} : { version }) })) ?? []);
59
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import z from '@deepseek-ai/schemastery';
4
+ export const name = 'dsh-composition-doctor';
5
+ /** Public Cordis service dependency supplied by dsh-host-webserver. */
6
+ export const inject = ['webServer'];
7
+ export const Config = z.object({ reportDir: z.string() });
8
+ export const latestReportPath = '/dsh-composition-doctor/reports/latest';
9
+ function reportDirectory(config) {
10
+ return resolve(config.reportDir ?? '.dsh-composition-doctor/reports');
11
+ }
12
+ function send(response, status, body) {
13
+ response.statusCode = status;
14
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
15
+ response.setHeader('Cache-Control', 'no-store');
16
+ response.end(body);
17
+ }
18
+ async function latestReport(config) {
19
+ const directory = reportDirectory(config);
20
+ try {
21
+ const directoryStat = await lstat(directory);
22
+ if (!directoryStat.isDirectory())
23
+ return undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ // The CLI writes report.json. latest.json is accepted for integrations that
29
+ // copy a report into the plugin-owned directory under that conventional name.
30
+ for (const filename of ['report.json', 'latest.json']) {
31
+ try {
32
+ const candidate = resolve(directory, filename);
33
+ const fileStat = await lstat(candidate);
34
+ if (!fileStat.isFile())
35
+ continue;
36
+ const text = await readFile(candidate, 'utf8');
37
+ JSON.parse(text);
38
+ return text.endsWith('\n') ? text : `${text}\n`;
39
+ }
40
+ catch {
41
+ // A missing or malformed report is represented as 404 below. Details are
42
+ // not sent to the browser, so local paths and parser errors cannot leak.
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+ function route(config) {
48
+ return {
49
+ kind: 'exact',
50
+ path: latestReportPath,
51
+ method: 'GET',
52
+ async handler(request, response) {
53
+ if (request.method !== undefined && request.method.toUpperCase() !== 'GET') {
54
+ response.statusCode = 405;
55
+ response.setHeader('Allow', 'GET');
56
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
57
+ response.end('{"error":"method_not_allowed"}\n');
58
+ return;
59
+ }
60
+ const report = await latestReport(config);
61
+ if (report === undefined) {
62
+ send(response, 404, '{"error":"latest_report_not_found"}\n');
63
+ return;
64
+ }
65
+ send(response, 200, report);
66
+ }
67
+ };
68
+ }
69
+ /** Registers the one read-only report route inside a Cordis effect scope. */
70
+ export function apply(ctx, config = {}) {
71
+ if (ctx.webServer === undefined)
72
+ return undefined;
73
+ const register = () => ctx.webServer.register(route(config));
74
+ return ctx.effect === undefined ? register() : ctx.effect(register);
75
+ }
@@ -0,0 +1,3 @@
1
+ export function renderJson(report) {
2
+ return `${JSON.stringify(report, null, 2)}\n`;
3
+ }
@@ -0,0 +1,14 @@
1
+ function formatEvidence(item) {
2
+ const subject = item.subject === undefined ? '' : ` — ${item.subject}`;
3
+ const packageLabel = item.packageName === undefined ? '' : ` (${item.packageName}${item.version === undefined ? '' : `@${item.version}`})`;
4
+ return `- \`${item.source}\`${subject}${packageLabel}: ${item.detail}`;
5
+ }
6
+ function formatDiagnostic(item) {
7
+ const evidence = item.evidence.length === 0 ? '- No concrete source evidence was supplied.' : item.evidence.map(formatEvidence).join('\n');
8
+ return `## [${item.severity.toUpperCase()}] ${item.title}\n\n${item.explanation}\n\nEvidence:\n${evidence}\n\nRemediation: ${item.remediation}`;
9
+ }
10
+ export function renderMarkdown(report) {
11
+ const summary = ['error', 'warning', 'info'].map((severity) => `${severity}: ${report.diagnostics.filter((item) => item.severity === severity).length}`).join(', ');
12
+ const body = report.diagnostics.length === 0 ? 'No diagnostics were produced.' : report.diagnostics.map(formatDiagnostic).join('\n\n');
13
+ return `# DSH Composition Doctor Report\n\nSchema: ${report.schemaVersion}\n\nProfile: \`${report.profileDir}\`\n\nGenerated: ${report.generatedAt}\n\nSummary: ${summary}\n\n${body}\n`;
14
+ }