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.
@@ -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 stringify(redact(parse(text)));
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
- 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)
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 isolatedModel = runtimeForTarget(await resolveComposition(isolatedInput), options.targetDsh);
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
- diagnostics = report.diagnostics;
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
- return { profileDir: resolvedRoot, files };
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
  }
@@ -1,4 +1,5 @@
1
- const secretProperty = /key|token|secret|password|credential|authorization/i;
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
+ }
@@ -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 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.'));
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
- 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)
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
- 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.'));
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
- for (const [claim, values] of groups(model.uiClaims ?? [], (value) => `${value.kind}:${value.value}`)) {
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
- 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.'));
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}; 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.'));
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
- if (platform === undefined || !requirement.supported.includes(platform)) {
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
- return { schemaVersion: 1, generatedAt: new Date().toISOString(), profileDir: model.profileDir, diagnostics };
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
  }
@@ -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
- return value !== null && typeof value === 'object' && !Array.isArray(value);
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 stringMap(value) {
6
- if (!isRecord(value))
7
- return [];
8
- return Object.entries(value).flatMap(([name, version]) => typeof version === 'string' ? [[name, version]] : []);
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 sortBySource(values) {
11
- return [...values].sort((left, right) => left.source.localeCompare(right.source) || JSON.stringify(left).localeCompare(JSON.stringify(right)));
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 manifestSnapshot(input) {
14
- const file = input.files.find((candidate) => candidate.relativePath === 'package.json');
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 { plugins: [], peers: [], platforms: [] };
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
- 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: [] };
43
+ profileName = manifest === undefined ? undefined : JSON.parse(manifest.text).name;
33
44
  }
34
- }
35
- function snapshotFromModel(model, profile, hashes, plugins = []) {
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: 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 }) }))),
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
- 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 }) })) ?? []);
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
  }
@@ -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 resolve(config.reportDir ?? '.dsh-composition-doctor/reports');
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
+ }
@@ -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
- return `# DSH Composition Doctor Report\n\nSchema: ${report.schemaVersion}\n\nProfile: \`${report.profileDir}\`\n\nGenerated: ${report.generatedAt}\n\nSummary: ${summary}\n\n${body}\n`;
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
  }