dsh-composition-doctor 0.1.3 → 0.2.1

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.
@@ -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,12 +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
- const evidenceMode = model.evidenceMode ?? 'static';
159
+ const evidenceMode = runtimeObserved ? 'runtime-observed' : requestedMode === 'runtime-observed' ? 'composed' : requestedMode;
110
160
  const unverifiedFindings = diagnostics.filter((item) => item.id === 'runtime-composition-unavailable' || item.evidence.some((entry) => entry.evidenceKind === 'static')).map((item) => item.id);
111
- return { schemaVersion: 1, generatedAt: new Date().toISOString(), profileDir: model.profileDir, evidenceMode, ...(model.metadataCoverage === undefined ? {} : { metadataCoverage: model.metadataCoverage }), unverifiedFindings, diagnostics };
161
+ return { schemaVersion: 1, evidenceSchemaVersion: 2, generatedAt: new Date().toISOString(), profileDir: model.profileDir, evidenceMode, runtimeObserved, ...(model.metadataCoverage === undefined ? {} : { metadataCoverage: model.metadataCoverage }), unverifiedFindings, diagnostics };
112
162
  }
@@ -1,59 +1,79 @@
1
+ import { basename, isAbsolute } 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 slashPath(value) { return value.replaceAll('\\', '/'); }
6
+ function isWindowsAbsolute(value) { return /^[A-Za-z]:\//.test(value); }
7
+ function normalizedSource(source, profileDir) {
8
+ const normalized = slashPath(source);
9
+ const root = slashPath(profileDir).replace(/\/+$/, '');
10
+ if (!isAbsolute(source) && !isWindowsAbsolute(normalized))
11
+ return normalized;
12
+ if (normalized === root)
13
+ return '<PROFILE>';
14
+ const prefix = `${root}/`;
15
+ return normalized.startsWith(prefix) ? `<PROFILE>/${normalized.slice(prefix.length)}` : `<EXTERNAL>/${basename(normalized)}`;
4
16
  }
5
- function stringMap(value) {
6
- if (!isRecord(value))
7
- return [];
8
- return Object.entries(value).flatMap(([name, version]) => typeof version === 'string' ? [[name, version]] : []);
17
+ function sortJson(values) { return [...values].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); }
18
+ function packageSnapshot(fact) {
19
+ return {
20
+ name: fact.name,
21
+ ...(fact.requestedSpec === undefined ? {} : { requestedSpec: fact.requestedSpec }),
22
+ ...(fact.installedVersion === undefined ? {} : { installedVersion: fact.installedVersion }),
23
+ packageJsonSource: `<BUNDLE:${fact.name}>/package.json`,
24
+ ...(fact.bundlePatch === undefined ? {} : { bundlePatch: `<BUNDLE:${fact.name}>/${basename(fact.bundlePatch)}` }),
25
+ ...(fact.repository === undefined ? {} : { repository: fact.repository }),
26
+ ...(fact.gitRef === undefined ? {} : { gitRef: fact.gitRef }),
27
+ ...(fact.integrity === undefined ? {} : { integrity: fact.integrity }),
28
+ ...(fact.platform === undefined ? {} : { platform: [...fact.platform].sort() }),
29
+ ...(fact.peerDsh === undefined ? {} : { peerDsh: fact.peerDsh }),
30
+ ...(fact.peerCordis === undefined ? {} : { peerCordis: fact.peerCordis }),
31
+ ...(fact.engineNode === undefined ? {} : { engineNode: fact.engineNode })
32
+ };
9
33
  }
10
- function sortBySource(values) {
11
- return [...values].sort((left, right) => left.source.localeCompare(right.source) || JSON.stringify(left).localeCompare(JSON.stringify(right)));
34
+ function packageFromBundle(bundle, profileDir) {
35
+ 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
36
  }
13
- function manifestSnapshot(input) {
14
- const file = input.files.find((candidate) => candidate.relativePath === 'package.json');
37
+ function lockKind(input) {
38
+ const file = input?.files.find((item) => ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'].includes(item.relativePath));
15
39
  if (file === undefined)
16
- return { plugins: [], peers: [], platforms: [] };
40
+ return {};
41
+ return { kind: file.relativePath === 'pnpm-lock.yaml' ? 'pnpm' : file.relativePath === 'yarn.lock' ? 'yarn' : 'npm', lockfileHash: file.sha256 };
42
+ }
43
+ function snapshotFromModel(model, input) {
44
+ const profileDir = model.profileDir;
45
+ const report = analyseComposition(model);
46
+ const manifest = input?.files.find((candidate) => candidate.relativePath === 'package.json');
47
+ let profileName;
17
48
  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: [] };
49
+ profileName = manifest === undefined ? undefined : JSON.parse(manifest.text).name;
33
50
  }
34
- }
35
- function snapshotFromModel(model, profile, hashes, plugins = []) {
51
+ catch { /* hash-only metadata is still valid */ }
52
+ const packages = model.installedPackages?.map(packageSnapshot) ?? model.bundles?.map((bundle) => packageFromBundle(bundle, profileDir)) ?? [];
53
+ const hashes = Object.fromEntries((input?.files ?? []).filter((file) => ['package.json', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'].includes(file.relativePath)).map((file) => [file.relativePath, file.sha256]));
36
54
  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 }) }))),
55
+ schemaVersion: 2,
56
+ doctorVersion: '0.1.3',
57
+ runtime: { ...(model.runtime?.dsh === undefined ? {} : { dsh: model.runtime.dsh }), ...(model.runtime?.cordis === undefined ? {} : { cordis: model.runtime.cordis }), node: model.runtime?.node ?? process.versions.node, platform: model.runtime?.platform ?? process.platform },
58
+ profile: { ...(profileName === undefined ? {} : { name: profileName }), ...(manifest === undefined ? {} : { manifestHash: manifest.sha256 }) },
59
+ packages: sortJson(packages),
60
+ 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))),
61
+ hooks: sortJson((model.hooks ?? []).map(({ hook, source, packageName, version }) => ({ hook, source: normalizedSource(source, profileDir), ...(packageName === undefined ? {} : { packageName }), ...(version === undefined ? {} : { version }) }))),
62
+ 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 }) }))),
63
+ conflicts: sortJson(report.diagnostics.map((item) => ({ id: item.id, severity: item.severity, subjects: item.evidence.map((entry) => `${normalizedSource(entry.source, profileDir)}:${entry.subject ?? ''}`).sort() }))),
64
+ packageManager: lockKind(input),
46
65
  hashes
47
66
  };
48
67
  }
49
- /** Creates a structural, redacted snapshot from already allow-listed profile input or a resolved model. */
50
68
  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 }) })) ?? []);
69
+ if ('files' in input)
70
+ return snapshotFromModel(await resolveComposition(input), input);
71
+ return snapshotFromModel(input);
72
+ }
73
+ export function isSnapshotV2(value) { return isRecord(value) && value.schemaVersion === 2 && Array.isArray(value.packages) && isRecord(value.profile); }
74
+ export function isSnapshotV1(value) { return isRecord(value) && value.schemaVersion === 1 && Array.isArray(value.plugins); }
75
+ export function legacyPlugins(snapshot) {
76
+ if (isSnapshotV1(snapshot))
77
+ return snapshot.plugins;
78
+ return snapshot.packages.map((item) => ({ name: item.name, ...(item.installedVersion === undefined ? {} : { version: item.installedVersion }), source: item.packageJsonSource }));
59
79
  }
@@ -7,6 +7,7 @@ export const name = 'dsh-composition-doctor';
7
7
  export const inject = ['webServer'];
8
8
  export const Config = z.object({ reportDir: z.string().default(defaultReportDirectory) });
9
9
  export const latestReportPath = '/dsh-composition-doctor/reports/latest';
10
+ const maxReportBytes = 2 * 1024 * 1024;
10
11
  function reportDirectory(config) {
11
12
  return resolveReportDirectory(config.reportDir);
12
13
  }
@@ -34,8 +35,12 @@ async function latestReport(config) {
34
35
  const fileStat = await lstat(candidate);
35
36
  if (!fileStat.isFile())
36
37
  continue;
38
+ if (fileStat.size > maxReportBytes)
39
+ continue;
37
40
  const text = await readFile(candidate, 'utf8');
38
- JSON.parse(text);
41
+ const value = JSON.parse(text);
42
+ if (!isAnalysisReport(value))
43
+ continue;
39
44
  return text.endsWith('\n') ? text : `${text}\n`;
40
45
  }
41
46
  catch {
@@ -45,6 +50,13 @@ async function latestReport(config) {
45
50
  }
46
51
  return undefined;
47
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
+ }
48
60
  function route(config) {
49
61
  return {
50
62
  kind: 'exact',
@@ -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');
@@ -11,5 +11,5 @@ 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}\n\nProfile: \`${report.profileDir}\`\n\nGenerated: ${report.generatedAt}\n\nEvidence mode: ${report.evidenceMode}\n${coverage}\nSummary: ${summary}\n\n${body}\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`;
15
15
  }
@@ -0,0 +1,49 @@
1
+ /** JSON Schema for the report wire format. Report schemaVersion remains 1;
2
+ * evidenceSchemaVersion is additive and separates evidence capability names. */
3
+ export const analysisReportJsonSchema = {
4
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
5
+ type: 'object',
6
+ required: ['schemaVersion', 'evidenceSchemaVersion', 'generatedAt', 'profileDir', 'evidenceMode', 'runtimeObserved', 'unverifiedFindings', 'diagnostics'],
7
+ properties: {
8
+ schemaVersion: { const: 1 },
9
+ evidenceSchemaVersion: { const: 2 },
10
+ generatedAt: { type: 'string' },
11
+ profileDir: { type: 'string' },
12
+ evidenceMode: { enum: ['static', 'composed', 'runtime-observed', 'mixed'] },
13
+ runtimeObserved: { type: 'boolean' },
14
+ unverifiedFindings: { type: 'array', items: { type: 'string' } },
15
+ diagnostics: {
16
+ type: 'array',
17
+ items: {
18
+ type: 'object',
19
+ required: ['id', 'severity', 'title', 'evidence', 'explanation', 'remediation'],
20
+ properties: {
21
+ id: { type: 'string' },
22
+ severity: { enum: ['info', 'warning', 'error'] },
23
+ title: { type: 'string' },
24
+ explanation: { type: 'string' },
25
+ remediation: { type: 'string' },
26
+ evidence: {
27
+ type: 'array',
28
+ items: {
29
+ type: 'object',
30
+ required: ['source', 'detail', 'evidenceKind'],
31
+ properties: {
32
+ source: { type: 'string' },
33
+ detail: { type: 'string' },
34
+ evidenceKind: { enum: ['static', 'composed', 'runtime-observed'] },
35
+ subject: { type: 'string' },
36
+ packageName: { type: 'string' },
37
+ version: { type: 'string' },
38
+ severity: { enum: ['info', 'warning', 'error'] }
39
+ },
40
+ additionalProperties: false
41
+ }
42
+ }
43
+ },
44
+ additionalProperties: false
45
+ }
46
+ }
47
+ },
48
+ additionalProperties: true
49
+ };
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "dsh-composition-doctor",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "Read-only DSH and Cordis composition diagnostics, snapshots, diffs, and isolated preflight.",
6
6
  "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/lemonxiny55/dsh-composition-doctor.git"
10
+ },
7
11
  "main": "./dist/plugin/index.js",
12
+ "bin": {
13
+ "dsh-doctor": "./dist/cli/main.js"
14
+ },
8
15
  "exports": {
9
16
  ".": "./dist/plugin/index.js",
10
17
  "./client": "./dist/client.js",
@@ -16,6 +23,7 @@
16
23
  "cordis.patch.yml",
17
24
  "README.md",
18
25
  "README.zh.md",
26
+ "CHANGELOG.md",
19
27
  "LICENSE"
20
28
  ],
21
29
  "dsh": {
@@ -37,13 +45,6 @@
37
45
  "engines": {
38
46
  "node": ">=20"
39
47
  },
40
- "packageManager": "pnpm@10.17.1",
41
- "scripts": {
42
- "test": "pnpm build && vitest run",
43
- "typecheck": "tsc --noEmit",
44
- "build": "tsc && node scripts/build-client.mjs",
45
- "dsh-doctor": "node dist/cli/main.js"
46
- },
47
48
  "dependencies": {
48
49
  "@deepseek-ai/schemastery": "^3.18.1",
49
50
  "semver": "^7.7.2",
@@ -54,5 +55,11 @@
54
55
  "esbuild": "^0.28.2",
55
56
  "typescript": "^5.9.2",
56
57
  "vitest": "^3.2.4"
58
+ },
59
+ "scripts": {
60
+ "test": "pnpm build && vitest run",
61
+ "typecheck": "tsc --noEmit",
62
+ "build": "tsc && node scripts/build-client.mjs",
63
+ "dsh-doctor": "node dist/cli/main.js"
57
64
  }
58
- }
65
+ }