dsh-composition-doctor 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +23 -7
- package/README.zh.md +56 -11
- package/dist/cli/main.js +66 -18
- package/dist/client/index.js +5 -4
- package/dist/client/report-view.js +110 -11
- package/dist/client.js +112 -18
- package/dist/core/bundle-inventory.js +66 -0
- package/dist/core/composition-adapter.js +56 -9
- package/dist/core/composition-provenance.js +8 -0
- package/dist/core/diff.js +41 -36
- package/dist/core/dsh-dump-parser.js +111 -0
- package/dist/core/dsh-dump-provider.js +93 -0
- package/dist/core/package-provenance.js +80 -0
- package/dist/core/preflight/artifact-resolver.js +150 -0
- package/dist/core/preflight/candidate-artifact-resolver.js +61 -0
- package/dist/core/preflight/npm-artifact.js +132 -0
- package/dist/core/preflight/package-manager.js +4 -0
- package/dist/core/preflight/package-resolution.js +66 -0
- package/dist/core/preflight/rehearsal-profile.js +6 -0
- package/dist/core/preflight/smoke.js +1 -0
- package/dist/core/preflight.js +98 -11
- package/dist/core/profile-reader.js +16 -1
- package/dist/core/redaction.js +28 -1
- package/dist/core/rules.js +64 -12
- package/dist/core/snapshot.js +60 -46
- package/dist/plugin/index.js +16 -3
- package/dist/reports/location.js +9 -0
- package/dist/reports/markdown.js +3 -2
- package/dist/reports/schema.js +49 -0
- package/package.json +18 -10
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { extname, join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { redactYamlPreservingTags, redact } from './redaction.js';
|
|
7
|
+
import { parseDshDump } from './dsh-dump-parser.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const yamlFiles = new Set(['cordis.yml', 'cordis.patch.yml']);
|
|
10
|
+
function unavailable(reason) {
|
|
11
|
+
return {
|
|
12
|
+
id: 'runtime-composition-unavailable', severity: 'warning', title: 'Resolved composition is unavailable', evidence: [{ source: '<DSH provider>', detail: reason, evidenceKind: 'static' }],
|
|
13
|
+
explanation: 'The scan could not obtain final resolved rows from a public DSH dump-config provider, so it used bounded static evidence.',
|
|
14
|
+
remediation: 'Install a verified DSH CLI or pass a public provider in integration code; do not import private DSH loaders.'
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function pathCandidates() {
|
|
18
|
+
const values = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
|
|
19
|
+
return values.flatMap((directory) => process.platform === 'win32' ? [join(directory, 'dsh.cmd'), join(directory, 'dsh.exe'), join(directory, 'dsh')] : [join(directory, 'dsh')]);
|
|
20
|
+
}
|
|
21
|
+
async function findDsh() {
|
|
22
|
+
const explicit = process.env.DSH_DOCTOR_DSH_BIN;
|
|
23
|
+
const candidates = explicit === undefined ? pathCandidates() : [explicit];
|
|
24
|
+
for (const candidate of candidates) {
|
|
25
|
+
try {
|
|
26
|
+
if ((await stat(candidate)).isFile())
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
29
|
+
catch { /* executable lookup is best effort */ }
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
async function copyRehearsal(input, directory) {
|
|
34
|
+
for (const file of input.files) {
|
|
35
|
+
if (file.relativePath === 'package.json') {
|
|
36
|
+
await writeFile(join(directory, file.relativePath), `${JSON.stringify(redact(JSON.parse(file.text)), null, 2)}\n`, 'utf8');
|
|
37
|
+
}
|
|
38
|
+
else if (yamlFiles.has(file.relativePath)) {
|
|
39
|
+
await writeFile(join(directory, file.relativePath), redactYamlPreservingTags(file.text), 'utf8');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function runtimeUnknownDiagnostic(input) {
|
|
44
|
+
const tagged = input.files.find((file) => yamlFiles.has(file.relativePath) && /!<[^>]+>|!![A-Za-z0-9_/-]+/.test(file.text));
|
|
45
|
+
if (tagged === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
return {
|
|
48
|
+
id: 'runtime-behavior-unverified', severity: 'warning', title: 'Runtime-dependent configuration remains unverified',
|
|
49
|
+
evidence: [{ source: tagged.relativePath, detail: 'A custom YAML tag or runtime expression was preserved for dump-config but was not executed.', evidenceKind: 'composed' }],
|
|
50
|
+
explanation: 'A composed configuration can be structurally resolved while runtime-dependent values remain unknown. dump-config is not a runtime observation.',
|
|
51
|
+
remediation: 'Keep runtime-observed claims separate unless a supported isolated runtime observation backend is explicitly available.'
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export async function tryDshDump(input, options = {}) {
|
|
55
|
+
const dshBin = options.dshBin ?? await findDsh();
|
|
56
|
+
if (dshBin === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
const directory = await mkdtemp(join(tmpdir(), 'dsh-doctor-dump-'));
|
|
59
|
+
try {
|
|
60
|
+
const home = join(directory, 'dsh-home');
|
|
61
|
+
const isolatedName = 'doctor-isolated';
|
|
62
|
+
const profile = join(home, 'profiles', isolatedName);
|
|
63
|
+
await mkdir(profile, { recursive: true });
|
|
64
|
+
await copyRehearsal(input, profile);
|
|
65
|
+
const env = { PATH: process.env.PATH, TEMP: process.env.TEMP, TMP: process.env.TMP, DSH_HOME: home };
|
|
66
|
+
const extension = extname(dshBin).toLowerCase();
|
|
67
|
+
const script = ['.js', '.mjs', '.cjs'].includes(extension);
|
|
68
|
+
const commandShim = process.platform === 'win32' && (extension === '.cmd' || extension === '.bat');
|
|
69
|
+
const executable = script ? process.execPath : commandShim ? (process.env.ComSpec ?? 'cmd.exe') : dshBin;
|
|
70
|
+
const args = script ? [dshBin, '--profile', isolatedName, '--dump-config'] : commandShim ? ['/d', '/s', '/c', `call "${dshBin}" --profile ${isolatedName} --dump-config`] : ['--profile', isolatedName, '--dump-config'];
|
|
71
|
+
try {
|
|
72
|
+
const result = await execFileAsync(executable, args, { cwd: directory, env, timeout: options.timeoutMs ?? 20_000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 });
|
|
73
|
+
const parsed = parseDshDump(result.stdout, result.stderr);
|
|
74
|
+
const runtimeUnknown = runtimeUnknownDiagnostic(input);
|
|
75
|
+
return { rows: parsed.rows, diagnostics: runtimeUnknown === undefined ? parsed.diagnostics : [...parsed.diagnostics, runtimeUnknown] };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
79
|
+
// The caller must keep the static rows when the public command cannot
|
|
80
|
+
// execute. A default provider failure is represented by the bounded
|
|
81
|
+
// static-fallback diagnostic in composition-adapter.
|
|
82
|
+
void detail;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
void error;
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await rm(directory, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
function record(value) {
|
|
5
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
6
|
+
}
|
|
7
|
+
function string(value) {
|
|
8
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
function repositoryValue(value) {
|
|
11
|
+
if (typeof value === 'string')
|
|
12
|
+
return value;
|
|
13
|
+
const object = record(value);
|
|
14
|
+
return object === undefined ? undefined : string(object.url);
|
|
15
|
+
}
|
|
16
|
+
function gitRefFrom(repository, manifest) {
|
|
17
|
+
const explicit = string(manifest.gitHead);
|
|
18
|
+
if (explicit !== undefined)
|
|
19
|
+
return explicit;
|
|
20
|
+
const match = repository?.match(/#([0-9a-f]{7,40})$/i);
|
|
21
|
+
return match?.[1];
|
|
22
|
+
}
|
|
23
|
+
function packageJsonPath(profileDir, name) {
|
|
24
|
+
if (!/^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/i.test(name))
|
|
25
|
+
throw new Error(`Invalid bundle package name: ${name}`);
|
|
26
|
+
const require = createRequire(resolve(profileDir, 'package.json'));
|
|
27
|
+
return require.resolve(`${name}/package.json`);
|
|
28
|
+
}
|
|
29
|
+
function within(root, candidate) {
|
|
30
|
+
const relativePath = relative(root, candidate);
|
|
31
|
+
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
|
32
|
+
}
|
|
33
|
+
export async function inspectInstalledPackage(profileDir, name, requestedSpec) {
|
|
34
|
+
const profileRoot = await realpath(profileDir);
|
|
35
|
+
const source = packageJsonPath(profileDir, name);
|
|
36
|
+
const resolvedSource = await realpath(source);
|
|
37
|
+
if (!within(profileRoot, resolvedSource))
|
|
38
|
+
throw new Error(`Bundle package resolves outside the selected profile: ${name}`);
|
|
39
|
+
const manifest = JSON.parse(await readFile(resolvedSource, 'utf8'));
|
|
40
|
+
const dsh = record(manifest.dsh);
|
|
41
|
+
const bundle = record(dsh?.bundle);
|
|
42
|
+
const dist = record(manifest.dist);
|
|
43
|
+
const peers = record(manifest.peerDependencies);
|
|
44
|
+
const engines = record(manifest.engines);
|
|
45
|
+
const scripts = record(manifest.scripts);
|
|
46
|
+
const peerDsh = string(peers?.['@deepseek-ai/dsh']);
|
|
47
|
+
const peerCordis = string(peers?.['@deepseek-ai/cordis']) ?? string(peers?.cordis) ?? string(peers?.['@cordisjs/core']);
|
|
48
|
+
const engineNode = string(engines?.node);
|
|
49
|
+
const repository = repositoryValue(manifest.repository);
|
|
50
|
+
const fileStat = await stat(source);
|
|
51
|
+
const declaredPatch = string(bundle?.patch);
|
|
52
|
+
const patchPath = declaredPatch === undefined ? undefined : resolve(dirname(resolvedSource), declaredPatch);
|
|
53
|
+
if (patchPath !== undefined && !within(profileRoot, patchPath))
|
|
54
|
+
throw new Error(`Bundle patch resolves outside the selected profile: ${name}`);
|
|
55
|
+
const safePatch = patchPath === undefined ? undefined : await realpath(patchPath).then((resolvedPatch) => {
|
|
56
|
+
if (!within(profileRoot, resolvedPatch))
|
|
57
|
+
throw new Error(`Bundle patch resolves outside the selected profile: ${name}`);
|
|
58
|
+
return resolvedPatch;
|
|
59
|
+
}).catch((error) => {
|
|
60
|
+
if (error.code === 'ENOENT')
|
|
61
|
+
return patchPath;
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
...(requestedSpec === undefined ? {} : { requestedSpec }),
|
|
67
|
+
...(string(manifest.version) === undefined ? {} : { installedVersion: string(manifest.version) }),
|
|
68
|
+
packageJsonSource: source,
|
|
69
|
+
...(safePatch === undefined ? {} : { bundlePatch: safePatch }),
|
|
70
|
+
...(repository === undefined ? {} : { repository }),
|
|
71
|
+
...(gitRefFrom(repository, manifest) === undefined ? {} : { gitRef: gitRefFrom(repository, manifest) }),
|
|
72
|
+
...(string(dist?.integrity) === undefined ? {} : { integrity: string(dist?.integrity) }),
|
|
73
|
+
modifiedAt: fileStat.mtime.toISOString(),
|
|
74
|
+
...(Array.isArray(manifest.os) ? { platform: manifest.os.filter((item) => typeof item === 'string') } : {}),
|
|
75
|
+
...(peerDsh === undefined ? {} : { peerDsh }),
|
|
76
|
+
...(peerCordis === undefined ? {} : { peerCordis }),
|
|
77
|
+
...(engineNode === undefined ? {} : { engineNode }),
|
|
78
|
+
...(scripts === undefined ? {} : { lifecycleScripts: ['preinstall', 'install', 'postinstall', 'prepare'].filter((key) => typeof scripts[key] === 'string') })
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { delimiter, join, resolve } from 'node:path';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { downloadNpmArtifact, fileDigest, readNpmCacheMetadata, tarManifest } from './npm-artifact.js';
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
async function readableFile(path) {
|
|
10
|
+
try {
|
|
11
|
+
return (await stat(path)).isFile();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function readableDirectory(path) {
|
|
18
|
+
try {
|
|
19
|
+
return (await stat(path)).isDirectory();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function respondsToVersion(path) {
|
|
26
|
+
try {
|
|
27
|
+
const shim = process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(path);
|
|
28
|
+
const executable = shim ? (process.env.ComSpec ?? 'cmd.exe') : path;
|
|
29
|
+
const args = shim ? ['/d', '/s', '/c', `call "${path}" --version`] : ['--version'];
|
|
30
|
+
await execFileAsync(executable, args, { windowsHide: true, timeout: 5_000, maxBuffer: 128 * 1024 });
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function explicitPackage(path, targetDsh) {
|
|
38
|
+
if (await readableFile(path)) {
|
|
39
|
+
if (/\.(?:tgz|tar|tar\.gz)$/i.test(path)) {
|
|
40
|
+
try {
|
|
41
|
+
const bytes = await readFile(path);
|
|
42
|
+
const manifest = tarManifest(bytes);
|
|
43
|
+
const digest = await fileDigest(path);
|
|
44
|
+
const resolvedVersion = typeof manifest.version === 'string' ? manifest.version : undefined;
|
|
45
|
+
return { kind: 'dsh-tarball', path, detail: resolvedVersion === targetDsh ? `Using explicit local DSH ${targetDsh} tarball.` : `Using local DSH tarball candidate ${resolvedVersion ?? 'unknown'}; target ${targetDsh} is not verified.`, targetVersionVerified: resolvedVersion === targetDsh, source: 'explicit-local', requestedVersion: targetDsh, resolvedVersion, manifest, ...digest };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { kind: 'unavailable', detail: `Explicit DSH tarball could not be inspected: ${path}`, targetVersionVerified: false, source: 'unavailable', requestedVersion: targetDsh };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { kind: 'unavailable', detail: `Explicit DSH package path is a file but not a supported local tarball: ${path}`, targetVersionVerified: false, source: 'unavailable', requestedVersion: targetDsh };
|
|
52
|
+
}
|
|
53
|
+
if (!(await readableDirectory(path)))
|
|
54
|
+
return { kind: 'unavailable', detail: `Explicit DSH package path is not readable: ${path}`, targetVersionVerified: false, source: 'unavailable', requestedVersion: targetDsh };
|
|
55
|
+
try {
|
|
56
|
+
const manifest = JSON.parse(await readFile(join(path, 'package.json'), 'utf8'));
|
|
57
|
+
const version = typeof manifest.version === 'string' ? manifest.version : undefined;
|
|
58
|
+
const hasBin = typeof manifest.bin === 'string' || (manifest.bin !== null && typeof manifest.bin === 'object');
|
|
59
|
+
const digest = await fileDigest(join(path, 'package.json'));
|
|
60
|
+
if (!hasBin)
|
|
61
|
+
return { kind: 'unavailable', detail: `Local DSH package has no public bin entry: ${path}`, targetVersionVerified: false, source: 'unavailable', requestedVersion: targetDsh, resolvedVersion: version, manifest, ...digest };
|
|
62
|
+
return { kind: 'dsh-package', path, detail: version === targetDsh ? `Using local DSH package ${targetDsh}.` : `Using local DSH package candidate ${version ?? 'unknown'}; target ${targetDsh} is not verified.`, targetVersionVerified: version === targetDsh, source: 'explicit-local', requestedVersion: targetDsh, resolvedVersion: version, manifest, ...digest };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { kind: 'unavailable', detail: `Local DSH package has no readable package.json: ${path}`, targetVersionVerified: false, source: 'unavailable', requestedVersion: targetDsh };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function cachedTarball(targetDsh, cacheDir, source) {
|
|
69
|
+
try {
|
|
70
|
+
const names = await readdir(cacheDir);
|
|
71
|
+
const path = names.map((name) => resolve(cacheDir, name)).find((candidate) => candidate.endsWith('.tgz') && candidate.includes(targetDsh));
|
|
72
|
+
if (path === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (!(await lstat(path)).isFile())
|
|
75
|
+
return undefined;
|
|
76
|
+
const bytes = await readFile(path);
|
|
77
|
+
const manifest = tarManifest(bytes);
|
|
78
|
+
const resolvedVersion = typeof manifest.version === 'string' ? manifest.version : undefined;
|
|
79
|
+
const digest = await fileDigest(path);
|
|
80
|
+
if (source === 'doctor-cache') {
|
|
81
|
+
const metadata = await readNpmCacheMetadata(path);
|
|
82
|
+
if (metadata === undefined || metadata.resolvedVersion !== resolvedVersion || metadata.sha512 !== digest.sha512 || metadata.integrity !== digest.integrity)
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const label = source === 'doctor-cache' ? 'Doctor cache' : 'package-manager cache/store';
|
|
86
|
+
return { kind: 'dsh-tarball', path, detail: resolvedVersion === targetDsh ? `Using ${label} artifact for DSH ${targetDsh}.` : `${label} contains DSH ${resolvedVersion ?? 'unknown'}, not requested ${targetDsh}.`, targetVersionVerified: resolvedVersion === targetDsh, source, requestedVersion: targetDsh, resolvedVersion, manifest, ...digest };
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function installedDsh() {
|
|
93
|
+
const pathValue = process.env.PATH ?? '';
|
|
94
|
+
for (const directory of pathValue.split(delimiter).filter(Boolean)) {
|
|
95
|
+
const candidates = process.platform === 'win32' ? [join(directory, 'dsh.cmd'), join(directory, 'dsh.exe'), join(directory, 'dsh')] : [join(directory, 'dsh')];
|
|
96
|
+
for (const candidate of candidates)
|
|
97
|
+
if (await readableFile(candidate) && await respondsToVersion(candidate)) {
|
|
98
|
+
try {
|
|
99
|
+
const shim = process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(candidate);
|
|
100
|
+
const executable = shim ? (process.env.ComSpec ?? 'cmd.exe') : candidate;
|
|
101
|
+
const args = shim ? ['/d', '/s', '/c', `call "${candidate}" --version`] : ['--version'];
|
|
102
|
+
const result = await execFileAsync(executable, args, { windowsHide: true, timeout: 5_000, maxBuffer: 128 * 1024 });
|
|
103
|
+
return { path: candidate, version: `${result.stdout}\n${result.stderr}`.trim() };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { path: candidate };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/** Offline-first target artifact discovery. No registry, Git, cache install, or lifecycle is performed. */
|
|
113
|
+
export async function resolveDshArtifact(options) {
|
|
114
|
+
if (options.dshBin !== undefined) {
|
|
115
|
+
if (await readableFile(options.dshBin))
|
|
116
|
+
return { kind: 'dsh-bin', path: options.dshBin, detail: `Using explicit local DSH binary candidate for ${options.targetDsh}; release version is not verified until --version is checked.`, targetVersionVerified: false, source: 'explicit-local', requestedVersion: options.targetDsh, ...(await fileDigest(options.dshBin)) };
|
|
117
|
+
return { kind: 'unavailable', detail: `Explicit DSH binary path is not readable: ${options.dshBin}`, targetVersionVerified: false, source: 'unavailable', requestedVersion: options.targetDsh };
|
|
118
|
+
}
|
|
119
|
+
if (options.dshPackage !== undefined)
|
|
120
|
+
return explicitPackage(options.dshPackage, options.targetDsh);
|
|
121
|
+
const installed = await installedDsh();
|
|
122
|
+
if (installed !== undefined)
|
|
123
|
+
return { kind: 'dsh-bin', path: installed.path, detail: `Using installed local dsh candidate for ${options.targetDsh}; release version is not verified.`, targetVersionVerified: installed.version?.includes(options.targetDsh) === true, source: 'installed', requestedVersion: options.targetDsh, resolvedVersion: installed.version, ...(await fileDigest(installed.path)) };
|
|
124
|
+
for (const directory of options.packageManagerCacheDirs ?? []) {
|
|
125
|
+
const cached = await cachedTarball(options.targetDsh, directory, 'package-manager-cache');
|
|
126
|
+
if (cached !== undefined && cached.targetVersionVerified)
|
|
127
|
+
return cached;
|
|
128
|
+
}
|
|
129
|
+
if (options.cacheDir !== undefined) {
|
|
130
|
+
const cached = await cachedTarball(options.targetDsh, options.cacheDir, 'doctor-cache');
|
|
131
|
+
if (cached !== undefined && cached.targetVersionVerified)
|
|
132
|
+
return cached;
|
|
133
|
+
}
|
|
134
|
+
if (options.online) {
|
|
135
|
+
try {
|
|
136
|
+
const downloaded = await downloadNpmArtifact(`@deepseek-ai/dsh@${options.targetDsh}`, options.cacheDir ?? join(tmpdir(), 'dsh-composition-doctor-cache'));
|
|
137
|
+
return { kind: 'dsh-tarball', path: downloaded.path, detail: `Downloaded official DSH ${downloaded.resolvedVersion} artifact from npm registry into Doctor-owned cache.`, targetVersionVerified: downloaded.resolvedVersion === options.targetDsh, source: downloaded.source, requestedVersion: options.targetDsh, resolvedVersion: downloaded.resolvedVersion, manifest: downloaded.manifest, sha512: downloaded.sha512, integrity: downloaded.integrity };
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return { kind: 'unavailable', detail: `Official npm artifact download failed for DSH ${options.targetDsh}: ${error instanceof Error ? error.message : String(error)}`, targetVersionVerified: false, source: 'unavailable', requestedVersion: options.targetDsh };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
kind: 'unavailable',
|
|
145
|
+
detail: `Target DSH ${options.targetDsh} artifact unavailable offline; no permitted local package-manager cache/store or Doctor cache contained the exact release.`,
|
|
146
|
+
targetVersionVerified: false,
|
|
147
|
+
source: 'unavailable',
|
|
148
|
+
requestedVersion: options.targetDsh
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { downloadNpmArtifact, fileDigest, tarManifest } from './npm-artifact.js';
|
|
5
|
+
function candidateParts(candidate) {
|
|
6
|
+
const at = candidate.lastIndexOf('@');
|
|
7
|
+
return { name: candidate.slice(0, at), version: candidate.slice(at + 1) };
|
|
8
|
+
}
|
|
9
|
+
function packagePath(profileDir, name) {
|
|
10
|
+
return resolve(profileDir, 'node_modules', ...name.split('/'), 'package.json');
|
|
11
|
+
}
|
|
12
|
+
/** Reads only an already-installed candidate manifest; it never invokes a package manager. */
|
|
13
|
+
export async function resolveCandidateArtifact(profileDir, candidate, online, cacheDir, packageManagerCacheDirs) {
|
|
14
|
+
const { name, version } = candidateParts(candidate);
|
|
15
|
+
const packageJsonPath = packagePath(profileDir, name);
|
|
16
|
+
try {
|
|
17
|
+
const manifest = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
|
18
|
+
const installedVersion = typeof manifest.version === 'string' ? manifest.version : 'unknown';
|
|
19
|
+
if (installedVersion !== version)
|
|
20
|
+
return { status: 'unavailable', candidate, detail: `Local candidate manifest version ${installedVersion} does not match requested ${version}.` };
|
|
21
|
+
const scripts = manifest.scripts ?? {};
|
|
22
|
+
const lifecycleScripts = ['preinstall', 'install', 'postinstall', 'prepare'].filter((key) => typeof scripts[key] === 'string');
|
|
23
|
+
return { status: 'resolved', candidate, detail: 'Inspected an existing local candidate manifest; no install or lifecycle was executed.', artifact: { name, requestedVersion: version, installedVersion, packageJsonPath, lifecycleScripts, source: 'profile-node_modules', manifest } };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
for (const directory of packageManagerCacheDirs ?? []) {
|
|
27
|
+
try {
|
|
28
|
+
const nameInFilename = name.replaceAll('/', '-');
|
|
29
|
+
const filename = (await readdir(directory)).find((entry) => entry.endsWith('.tgz') && entry.includes(nameInFilename) && entry.includes(version));
|
|
30
|
+
if (filename === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
const tarballPath = resolve(directory, filename);
|
|
33
|
+
if (!(await lstat(tarballPath)).isFile())
|
|
34
|
+
continue;
|
|
35
|
+
const manifest = tarManifest(await readFile(tarballPath));
|
|
36
|
+
if (manifest.name !== name || manifest.version !== version)
|
|
37
|
+
continue;
|
|
38
|
+
const scripts = manifest.scripts !== null && typeof manifest.scripts === 'object' ? manifest.scripts : {};
|
|
39
|
+
const lifecycleScripts = ['preinstall', 'install', 'postinstall', 'prepare'].filter((key) => typeof scripts[key] === 'string');
|
|
40
|
+
const digest = await fileDigest(tarballPath);
|
|
41
|
+
return { status: 'resolved', candidate, detail: 'Inspected an exact package-manager cache/store tarball; no install or lifecycle was executed.', artifact: { name, requestedVersion: version, installedVersion: version, packageJsonPath: tarballPath, lifecycleScripts, source: 'package-manager-cache', manifest, integrity: digest.integrity, sha512: digest.sha512 } };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// An unreadable cache entry is not evidence of an unavailable release.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!online)
|
|
48
|
+
return { status: 'unavailable', candidate, detail: `${candidate} is not available as an exact local profile node_modules artifact offline.` };
|
|
49
|
+
try {
|
|
50
|
+
const downloaded = await downloadNpmArtifact(candidate, cacheDir ?? resolve(tmpdir(), 'dsh-composition-doctor-cache'));
|
|
51
|
+
if (downloaded.resolvedVersion !== version)
|
|
52
|
+
return { status: 'unavailable', candidate, detail: `npm resolved ${downloaded.resolvedVersion}, not requested ${version}; the downloaded artifact was not accepted.` };
|
|
53
|
+
const scripts = downloaded.manifest.scripts !== null && typeof downloaded.manifest.scripts === 'object' ? downloaded.manifest.scripts : {};
|
|
54
|
+
const lifecycleScripts = ['preinstall', 'install', 'postinstall', 'prepare'].filter((key) => typeof scripts[key] === 'string');
|
|
55
|
+
return { status: 'resolved', candidate, detail: `Downloaded and inspected the exact npm artifact into Doctor-owned cache; no install or lifecycle was executed.`, artifact: { name, requestedVersion: version, installedVersion: downloaded.resolvedVersion, packageJsonPath: downloaded.path, lifecycleScripts, source: 'npm-registry', manifest: downloaded.manifest, integrity: downloaded.integrity, sha512: downloaded.sha512, registry: downloaded.registry } };
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return { status: 'unavailable', candidate, detail: `Candidate npm artifact download failed for ${candidate}: ${error instanceof Error ? error.message : String(error)}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { gunzipSync } from 'node:zlib';
|
|
5
|
+
function text(bytes) {
|
|
6
|
+
return new TextDecoder().decode(bytes).replace(/\0+$/g, '');
|
|
7
|
+
}
|
|
8
|
+
export function tarManifest(bytes) {
|
|
9
|
+
// npm publishes gzip-compressed tar archives (.tgz). Keep accepting a
|
|
10
|
+
// plain tar too because explicit local fixtures are useful in unit tests.
|
|
11
|
+
const archive = bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : bytes;
|
|
12
|
+
let manifest;
|
|
13
|
+
for (let offset = 0; offset + 512 <= archive.length;) {
|
|
14
|
+
const name = text(archive.slice(offset, offset + 100));
|
|
15
|
+
if (name.length === 0)
|
|
16
|
+
break;
|
|
17
|
+
const normalizedName = name.replaceAll('\\', '/');
|
|
18
|
+
const pathParts = normalizedName.split('/');
|
|
19
|
+
const type = archive[offset + 156];
|
|
20
|
+
if (normalizedName.startsWith('/') || /^[A-Za-z]:\//.test(normalizedName) || name.includes('\\') || pathParts.includes('..')) {
|
|
21
|
+
throw new Error(`npm tarball contains an unsafe path: ${name}`);
|
|
22
|
+
}
|
|
23
|
+
if (type === 49 || type === 50)
|
|
24
|
+
throw new Error(`npm tarball contains a link entry: ${name}`);
|
|
25
|
+
const sizeText = text(archive.slice(offset + 124, offset + 136)).trim();
|
|
26
|
+
const size = Number.parseInt(sizeText || '0', 8);
|
|
27
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
28
|
+
throw new Error(`npm tarball contains an invalid entry size: ${name}`);
|
|
29
|
+
const dataStart = offset + 512;
|
|
30
|
+
if (dataStart + size > archive.length)
|
|
31
|
+
throw new Error(`npm tarball entry is truncated: ${name}`);
|
|
32
|
+
if (normalizedName === 'package/package.json')
|
|
33
|
+
manifest = JSON.parse(text(archive.slice(dataStart, dataStart + size)));
|
|
34
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
35
|
+
}
|
|
36
|
+
if (manifest === undefined)
|
|
37
|
+
throw new Error('npm tarball has no package/package.json');
|
|
38
|
+
return manifest;
|
|
39
|
+
}
|
|
40
|
+
function digest(bytes) {
|
|
41
|
+
const hash = createHash('sha512').update(bytes);
|
|
42
|
+
const base64 = hash.digest('base64');
|
|
43
|
+
const sha512 = createHash('sha512').update(bytes).digest('hex');
|
|
44
|
+
return { sha512, integrity: `sha512-${base64}` };
|
|
45
|
+
}
|
|
46
|
+
function exactPackageSpec(requestedSpec) {
|
|
47
|
+
const separator = requestedSpec.lastIndexOf('@');
|
|
48
|
+
if (separator <= 0 || separator === requestedSpec.length - 1)
|
|
49
|
+
throw new Error(`Only exact npm package specs are supported: ${requestedSpec}`);
|
|
50
|
+
return { name: requestedSpec.slice(0, separator), version: requestedSpec.slice(separator + 1) };
|
|
51
|
+
}
|
|
52
|
+
function metadataPath(path) {
|
|
53
|
+
return `${path}.metadata.json`;
|
|
54
|
+
}
|
|
55
|
+
async function atomicWrite(path, bytes) {
|
|
56
|
+
const temporaryPath = `${path}.${randomBytes(12).toString('hex')}.partial`;
|
|
57
|
+
try {
|
|
58
|
+
await writeFile(temporaryPath, bytes, { flag: 'wx' });
|
|
59
|
+
await rename(temporaryPath, path);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function readNpmCacheMetadata(path) {
|
|
66
|
+
try {
|
|
67
|
+
const value = JSON.parse(await readFile(metadataPath(path), 'utf8'));
|
|
68
|
+
if (value === null || typeof value !== 'object')
|
|
69
|
+
return undefined;
|
|
70
|
+
const metadata = value;
|
|
71
|
+
if (typeof metadata.requestedSpec !== 'string' || typeof metadata.resolvedVersion !== 'string' || metadata.registry !== 'https://registry.npmjs.org' || typeof metadata.sha512 !== 'string' || typeof metadata.integrity !== 'string')
|
|
72
|
+
return undefined;
|
|
73
|
+
return metadata;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function registryJson(url) {
|
|
80
|
+
const response = await fetch(url, { headers: { accept: 'application/json' } });
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
throw new Error(`npm registry returned HTTP ${response.status} for ${url}`);
|
|
83
|
+
const value = await response.json();
|
|
84
|
+
if (value === null || typeof value !== 'object')
|
|
85
|
+
throw new Error(`npm registry returned invalid JSON for ${url}`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
/** Downloads one exact npm package tarball into Doctor-owned cache storage without install or lifecycle execution. */
|
|
89
|
+
export async function downloadNpmArtifact(requestedSpec, cacheDir) {
|
|
90
|
+
const directory = resolve(cacheDir);
|
|
91
|
+
await mkdir(directory, { recursive: true });
|
|
92
|
+
const { name, version } = exactPackageSpec(requestedSpec);
|
|
93
|
+
const metadataUrl = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
|
|
94
|
+
const packageMetadata = await registryJson(metadataUrl);
|
|
95
|
+
const versions = packageMetadata.versions;
|
|
96
|
+
const versionMetadata = versions !== null && typeof versions === 'object'
|
|
97
|
+
? versions[version]
|
|
98
|
+
: undefined;
|
|
99
|
+
if (versionMetadata === null || typeof versionMetadata !== 'object')
|
|
100
|
+
throw new Error(`npm registry has no exact version ${version} for ${name}`);
|
|
101
|
+
const metadata = versionMetadata;
|
|
102
|
+
const dist = metadata.dist;
|
|
103
|
+
if (dist === undefined)
|
|
104
|
+
throw new Error(`npm registry metadata has no dist information for ${requestedSpec}`);
|
|
105
|
+
const tarballUrl = dist?.tarball;
|
|
106
|
+
if (typeof tarballUrl !== 'string')
|
|
107
|
+
throw new Error(`npm registry metadata has no tarball URL for ${requestedSpec}`);
|
|
108
|
+
const response = await fetch(tarballUrl, { headers: { accept: 'application/octet-stream' } });
|
|
109
|
+
if (!response.ok)
|
|
110
|
+
throw new Error(`npm registry returned HTTP ${response.status} for ${tarballUrl}`);
|
|
111
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
112
|
+
const filename = basename(new URL(tarballUrl).pathname) || `${name.replaceAll('/', '-')}-${version}.tgz`;
|
|
113
|
+
const path = resolve(directory, filename);
|
|
114
|
+
const manifest = tarManifest(bytes);
|
|
115
|
+
const resolvedVersion = typeof manifest.version === 'string' ? manifest.version : undefined;
|
|
116
|
+
if (resolvedVersion === undefined)
|
|
117
|
+
throw new Error(`npm tarball has no resolved version for ${requestedSpec}`);
|
|
118
|
+
if (manifest.name !== name || resolvedVersion !== version)
|
|
119
|
+
throw new Error(`npm tarball manifest does not match exact request ${requestedSpec}`);
|
|
120
|
+
const computed = digest(bytes);
|
|
121
|
+
if (typeof dist.integrity === 'string' && dist.integrity !== computed.integrity)
|
|
122
|
+
throw new Error(`npm registry integrity mismatch for ${requestedSpec}`);
|
|
123
|
+
const integrity = typeof dist.integrity === 'string' ? dist.integrity : computed.integrity;
|
|
124
|
+
const cacheMetadata = { requestedSpec, resolvedVersion, registry: 'https://registry.npmjs.org', sha512: computed.sha512, integrity };
|
|
125
|
+
await atomicWrite(metadataPath(path), Buffer.from(`${JSON.stringify(cacheMetadata)}\n`, 'utf8'));
|
|
126
|
+
await atomicWrite(path, bytes);
|
|
127
|
+
return { path, requestedSpec, resolvedVersion, manifest, source: 'npm-registry', registry: 'https://registry.npmjs.org', ...computed, integrity };
|
|
128
|
+
}
|
|
129
|
+
export async function fileDigest(path) {
|
|
130
|
+
const bytes = await readFile(path);
|
|
131
|
+
return digest(bytes);
|
|
132
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { satisfies } from 'semver';
|
|
2
|
+
function evidence(source, subject, detail, severity) {
|
|
3
|
+
return { source, subject, detail, evidenceKind: 'static', ...(severity === undefined ? {} : { severity }) };
|
|
4
|
+
}
|
|
5
|
+
function rangeCheck(range, actual, subject, source, label, evidenceItems, checks) {
|
|
6
|
+
if (typeof range !== 'string')
|
|
7
|
+
return;
|
|
8
|
+
if (actual === undefined) {
|
|
9
|
+
checks[label] = 'unknown';
|
|
10
|
+
evidenceItems.push(evidence(source, subject, `${label} requires ${range}, but the selected runtime fact is unavailable.`, 'warning'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const matches = satisfies(actual, range, { includePrerelease: true });
|
|
15
|
+
checks[label] = matches ? 'pass' : 'fail';
|
|
16
|
+
evidenceItems.push(evidence(source, subject, `${label} requires ${range}; observed ${actual}.`, matches ? undefined : 'warning'));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
checks[label] = 'unknown';
|
|
20
|
+
evidenceItems.push(evidence(source, subject, `${label} declares an unsupported range ${range}.`, 'warning'));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function listConstraint(value, actual, source, subject, label, evidenceItems, checks) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return;
|
|
26
|
+
const values = Array.isArray(value) ? value.filter((item) => typeof item === 'string') : typeof value === 'string' ? [value] : [];
|
|
27
|
+
if (values.length === 0) {
|
|
28
|
+
checks[label] = 'unknown';
|
|
29
|
+
evidenceItems.push(evidence(source, subject, `${label} constraint could not be interpreted.`, 'warning'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const excluded = values.some((item) => item.startsWith('!') && item.slice(1) === actual);
|
|
33
|
+
const positive = values.filter((item) => !item.startsWith('!'));
|
|
34
|
+
const matches = !excluded && (positive.length === 0 || positive.includes(actual));
|
|
35
|
+
checks[label] = matches ? 'pass' : 'fail';
|
|
36
|
+
evidenceItems.push(evidence(source, subject, `${label} declares ${values.join(', ')}; observed ${actual}.`, matches ? undefined : 'warning'));
|
|
37
|
+
}
|
|
38
|
+
export function evaluatePackageResolution(input) {
|
|
39
|
+
const evidenceItems = [];
|
|
40
|
+
const checks = {};
|
|
41
|
+
const manifest = input.manifest;
|
|
42
|
+
if (manifest === undefined || input.resolvedVersion === undefined) {
|
|
43
|
+
return { packageName: input.packageName, requestedVersion: input.requestedVersion, resolvedVersion: input.resolvedVersion, status: 'unavailable', source: input.source, integrity: input.integrity, sha512: input.sha512, checks: { artifact: 'unknown' }, evidence: [evidence(input.source, input.packageName, 'Package metadata was not available for resolution.', 'warning')] };
|
|
44
|
+
}
|
|
45
|
+
checks.version = input.resolvedVersion === input.requestedVersion ? 'pass' : 'fail';
|
|
46
|
+
evidenceItems.push(evidence(input.source, input.packageName, `Requested ${input.requestedVersion}; resolved ${input.resolvedVersion}.`, checks.version === 'pass' ? undefined : 'error'));
|
|
47
|
+
const peers = manifest.peerDependencies !== null && typeof manifest.peerDependencies === 'object' ? manifest.peerDependencies : {};
|
|
48
|
+
rangeCheck(peers['@deepseek-ai/dsh'], input.runtime.dsh, input.packageName, input.source, 'DSH peer', evidenceItems, checks);
|
|
49
|
+
rangeCheck(peers['@deepseek-ai/cordis'], input.runtime.cordis, input.packageName, input.source, 'Cordis peer', evidenceItems, checks);
|
|
50
|
+
const engines = manifest.engines !== null && typeof manifest.engines === 'object' ? manifest.engines : {};
|
|
51
|
+
rangeCheck(engines.node, input.runtime.node, input.packageName, input.source, 'Node engine', evidenceItems, checks);
|
|
52
|
+
listConstraint(manifest.os, input.runtime.platform ?? process.platform, input.source, input.packageName, 'OS', evidenceItems, checks);
|
|
53
|
+
listConstraint(manifest.cpu, process.arch, input.source, input.packageName, 'CPU', evidenceItems, checks);
|
|
54
|
+
const repository = manifest.repository;
|
|
55
|
+
const gitRef = typeof manifest.gitHead === 'string' ? manifest.gitHead : undefined;
|
|
56
|
+
if (input.integrity !== undefined || gitRef !== undefined || repository !== undefined) {
|
|
57
|
+
evidenceItems.push(evidence(input.source, input.packageName, `Package provenance: ${input.integrity === undefined ? 'no registry integrity' : input.integrity}${gitRef === undefined ? '' : `; git ref ${gitRef}`}.`));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
checks.provenance = 'unknown';
|
|
61
|
+
evidenceItems.push(evidence(input.source, input.packageName, 'No registry integrity or fixed Git ref was supplied with this local artifact.', 'warning'));
|
|
62
|
+
}
|
|
63
|
+
const values = Object.values(checks);
|
|
64
|
+
const status = values.includes('fail') ? 'incompatible' : values.includes('unknown') ? 'unknown' : 'compatible';
|
|
65
|
+
return { packageName: input.packageName, requestedVersion: input.requestedVersion, resolvedVersion: input.resolvedVersion, status, source: input.source, integrity: input.integrity, sha512: input.sha512, checks, evidence: evidenceItems };
|
|
66
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
/** Creates only the directory boundary; callers must copy allow-listed files through AST redaction. */
|
|
3
|
+
export async function createRehearsalDirectory(directory) {
|
|
4
|
+
await mkdir(directory, { recursive: true });
|
|
5
|
+
return directory;
|
|
6
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const runtimeNotRun = { outcome: 'not-run', detail: 'Third-party runtime execution requires an explicit supported isolation backend; a temporary directory is not a security sandbox.' };
|