dsh-xray 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/xray.js +119 -0
- package/cordis.patch.yml +3 -4
- package/lib/collect/dump.js +51 -0
- package/lib/collect/static.js +118 -0
- package/lib/model.js +160 -0
- package/package.json +11 -1
package/bin/xray.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// dsh-xray CLI. Static analysis works even when dsh cannot boot;
|
|
4
|
+
// commands needing the composed tree degrade with a clear notice.
|
|
5
|
+
|
|
6
|
+
const { collectStatic } = require('../lib/collect/static.js');
|
|
7
|
+
const { collectDump } = require('../lib/collect/dump.js');
|
|
8
|
+
const model = require('../lib/model.js');
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = { _: [], profile: 'web', json: false };
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const a = argv[i];
|
|
14
|
+
if (a === '--profile' || a === '-p') args.profile = argv[++i];
|
|
15
|
+
else if (a === '--json') args.json = true;
|
|
16
|
+
else args._.push(a);
|
|
17
|
+
}
|
|
18
|
+
return args;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tryDump(profile) {
|
|
22
|
+
try {
|
|
23
|
+
return { dump: collectDump(profile), error: null };
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return { dump: null, error: `dump-config unavailable (${err.message.split('\n')[0]}); static-only mode` };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pad = (s, n) => String(s ?? '').padEnd(n);
|
|
30
|
+
|
|
31
|
+
function cmdAttribute(args) {
|
|
32
|
+
const data = collectStatic(args.profile);
|
|
33
|
+
const result = model.attribute(data);
|
|
34
|
+
if (args.json) return console.log(JSON.stringify(result, null, 2));
|
|
35
|
+
|
|
36
|
+
console.log(`# ${result.rows.length} rows in profile "${args.profile}"\n`);
|
|
37
|
+
for (const row of result.rows) {
|
|
38
|
+
const flags = row.disabled ? ' [disabled]' : '';
|
|
39
|
+
const over = row.overrides.length
|
|
40
|
+
? ` ← patched by ${row.overrides.map((o) => o.layer).join(', ')}`
|
|
41
|
+
: '';
|
|
42
|
+
console.log(`${pad(row.id, 28)} ${pad(row.origin?.layer, 32)}${over}${flags}`);
|
|
43
|
+
}
|
|
44
|
+
if (result.orphans.length) {
|
|
45
|
+
console.log(`\n! ${result.orphans.length} orphan override(s) targeting nonexistent rows (silently skipped by dsh):`);
|
|
46
|
+
for (const o of result.orphans) console.log(` ${o.id} in ${o.file}`);
|
|
47
|
+
}
|
|
48
|
+
for (const w of result.warnings) console.log(`! ${w}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cmdConflicts(args) {
|
|
52
|
+
const data = collectStatic(args.profile);
|
|
53
|
+
const result = model.conflicts(data);
|
|
54
|
+
if (args.json) return console.log(JSON.stringify(result, null, 2));
|
|
55
|
+
if (!result.length) return console.log('no contested rows: every field has a single writer');
|
|
56
|
+
for (const c of result) {
|
|
57
|
+
console.log(`${c.id}`);
|
|
58
|
+
for (const f of c.fields) {
|
|
59
|
+
console.log(` .${f.field}: ${f.writers.map((w) => w.layer).join(' → ')} (winner: ${f.winner})`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cmdDiff(args) {
|
|
65
|
+
const data = collectStatic(args.profile);
|
|
66
|
+
const { dump, error } = tryDump(args.profile);
|
|
67
|
+
if (error) { console.error(`! ${error}`); process.exitCode = 1; return; }
|
|
68
|
+
const result = model.diff(data, dump);
|
|
69
|
+
if (args.json) return console.log(JSON.stringify(result, null, 2));
|
|
70
|
+
|
|
71
|
+
const section = (title, items, fmt) => {
|
|
72
|
+
if (!items.length) return;
|
|
73
|
+
console.log(`\n${title} (${items.length})`);
|
|
74
|
+
for (const it of items) console.log(` ${fmt(it)}`);
|
|
75
|
+
};
|
|
76
|
+
section('declared but not in boot tree', result.missingFromActual, (r) => `${r.id} (${r.name})`);
|
|
77
|
+
section('in boot tree but undeclared', result.missingFromDeclared, (r) => `${r.id} (${r.name}) — dump says: ${r.provenance}`);
|
|
78
|
+
section('disabled-state mismatch', result.disabledMismatch, (r) => `${r.id}: declared=${r.declared} actual=${r.actual}`);
|
|
79
|
+
section('orphan overrides (silently skipped)', result.orphanOverrides, (r) => `${r.id} in ${r.file}`);
|
|
80
|
+
section('installed but inactive packages', result.inactivePackages, (r) => `${r.name}@${r.version}`);
|
|
81
|
+
const total = result.missingFromActual.length + result.missingFromDeclared.length
|
|
82
|
+
+ result.disabledMismatch.length + result.orphanOverrides.length + result.inactivePackages.length;
|
|
83
|
+
if (total === 0) console.log('declared and actual trees agree');
|
|
84
|
+
else process.exitCode = 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cmdSnapshot(args) {
|
|
88
|
+
const data = collectStatic(args.profile);
|
|
89
|
+
const { dump } = tryDump(args.profile);
|
|
90
|
+
console.log(JSON.stringify(model.snapshot(data, dump), null, 2));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const commands = {
|
|
94
|
+
attribute: cmdAttribute,
|
|
95
|
+
conflicts: cmdConflicts,
|
|
96
|
+
diff: cmdDiff,
|
|
97
|
+
snapshot: cmdSnapshot,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const args = parseArgs(process.argv.slice(2));
|
|
101
|
+
const cmd = commands[args._[0]];
|
|
102
|
+
if (!cmd) {
|
|
103
|
+
console.log(`dsh-xray — X-ray for your DeepSeek Harness
|
|
104
|
+
|
|
105
|
+
Usage: dsh-xray <command> [--profile web] [--json]
|
|
106
|
+
|
|
107
|
+
Commands:
|
|
108
|
+
attribute which layer introduced each row, and who patched it since
|
|
109
|
+
conflicts rows whose fields have multiple writers, and who wins
|
|
110
|
+
diff declared (static layers) vs actual (dump-config) tree
|
|
111
|
+
snapshot content-addressed lockfile of the effective composition`);
|
|
112
|
+
process.exit(args._[0] ? 2 : 0);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
cmd(args);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`error: ${err.message}`);
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
package/cordis.patch.yml
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Dump collector: runs `dsh --profile <p> --dump-config` and parses the
|
|
3
|
+
// composed tree, preserving dsh's own provenance comments (`# == <layer>`),
|
|
4
|
+
// which annotate the row group that follows them.
|
|
5
|
+
|
|
6
|
+
const { execFileSync } = require('node:child_process');
|
|
7
|
+
const YAML = require('yaml');
|
|
8
|
+
|
|
9
|
+
const jsTag = { tag: 'tag:yaml.org,2002:js', resolve: (str) => ({ $js: str }) };
|
|
10
|
+
|
|
11
|
+
function runDump(profileName, { dshBin = 'dsh' } = {}) {
|
|
12
|
+
return execFileSync(dshBin, ['--profile', profileName, '--dump-config'], {
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @returns {{rows: [{id, name, config, disabled, provenance: string|null}], raw}}
|
|
20
|
+
* provenance is dsh's own `# ==` annotation, e.g.
|
|
21
|
+
* "@deepseek-ai/dsh-base, patched by @deepseek-ai/dsh-web-app".
|
|
22
|
+
*/
|
|
23
|
+
function parseDump(text) {
|
|
24
|
+
const docs = YAML.parse(text, { customTags: [jsTag] });
|
|
25
|
+
if (!Array.isArray(docs)) throw new Error('dump-config did not yield a YAML array');
|
|
26
|
+
|
|
27
|
+
// Map each top-level row start line -> most recent `# ==` header.
|
|
28
|
+
const headerByLine = [];
|
|
29
|
+
let current = null;
|
|
30
|
+
const lines = text.split('\n');
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
const m = lines[i].match(/^# == (.+)$/);
|
|
33
|
+
if (m) current = m[1].trim();
|
|
34
|
+
if (/^- /.test(lines[i])) headerByLine.push(current);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rows = docs.map((row, i) => ({
|
|
38
|
+
id: row.id ?? null,
|
|
39
|
+
name: row.name ?? null,
|
|
40
|
+
config: row.config,
|
|
41
|
+
disabled: row.disabled === true,
|
|
42
|
+
provenance: headerByLine[i] ?? null,
|
|
43
|
+
}));
|
|
44
|
+
return { rows, raw: text };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function collectDump(profileName, opts) {
|
|
48
|
+
return parseDump(runDump(profileName, opts));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { collectDump, parseDump };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Static collector: reads the layer stack from DSH_HOME without running dsh.
|
|
3
|
+
// Layers, in application order: each profile bundle's patch, the profile's
|
|
4
|
+
// cordis.patch.yml, the home-level cordis.patch.yml.
|
|
5
|
+
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const YAML = require('yaml');
|
|
10
|
+
|
|
11
|
+
// `!!js` expressions are loader-evaluated; statically we keep them as opaque
|
|
12
|
+
// markers so comparison logic can treat them as "dynamic, not comparable".
|
|
13
|
+
const jsTag = {
|
|
14
|
+
tag: 'tag:yaml.org,2002:js',
|
|
15
|
+
resolve: (str) => ({ $js: str }),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function parseYaml(text, file) {
|
|
19
|
+
try {
|
|
20
|
+
return { value: YAML.parse(text, { customTags: [jsTag] }), error: null };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return { value: null, error: `${file}: ${err.message}` };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function dshHome() {
|
|
27
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJson(file) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Resolve a bundle package dir: profile node_modules flat closure. */
|
|
39
|
+
function resolveBundleDir(home, profileDir, name) {
|
|
40
|
+
const candidates = [
|
|
41
|
+
path.join(profileDir, 'node_modules', name),
|
|
42
|
+
path.join(home, 'profiles', 'node_modules', name),
|
|
43
|
+
];
|
|
44
|
+
for (const dir of candidates) {
|
|
45
|
+
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Collect the static layer stack for a profile.
|
|
52
|
+
* @returns {{home, profile, layers, packages, warnings}}
|
|
53
|
+
* layers: [{kind: 'bundle'|'profile-patch'|'home-patch', name, file, entries, hash}]
|
|
54
|
+
* packages: profile dependencies with a `dsh` field (mounted or not)
|
|
55
|
+
*/
|
|
56
|
+
function collectStatic(profileName) {
|
|
57
|
+
const home = dshHome();
|
|
58
|
+
const profileDir = path.join(home, 'profiles', profileName);
|
|
59
|
+
const warnings = [];
|
|
60
|
+
const layers = [];
|
|
61
|
+
|
|
62
|
+
const manifestFile = path.join(profileDir, 'package.json');
|
|
63
|
+
const manifest = readJson(manifestFile);
|
|
64
|
+
if (!manifest) {
|
|
65
|
+
throw new Error(`profile manifest not found: ${manifestFile}`);
|
|
66
|
+
}
|
|
67
|
+
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
68
|
+
|
|
69
|
+
for (const name of bundles) {
|
|
70
|
+
const dir = resolveBundleDir(home, profileDir, name);
|
|
71
|
+
if (!dir) {
|
|
72
|
+
warnings.push(`bundle not resolvable: ${name}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const pkg = readJson(path.join(dir, 'package.json'));
|
|
76
|
+
const rel = pkg?.dsh?.bundle?.patch;
|
|
77
|
+
if (!rel) {
|
|
78
|
+
warnings.push(`bundle ${name} has no dsh.bundle.patch`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const file = path.join(dir, rel);
|
|
82
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
83
|
+
const { value, error } = parseYaml(text, file);
|
|
84
|
+
if (error) warnings.push(error);
|
|
85
|
+
layers.push({
|
|
86
|
+
kind: 'bundle',
|
|
87
|
+
name,
|
|
88
|
+
version: pkg.version ?? null,
|
|
89
|
+
file,
|
|
90
|
+
entries: Array.isArray(value) ? value : [],
|
|
91
|
+
text,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const [kind, file] of [
|
|
96
|
+
['profile-patch', path.join(profileDir, 'cordis.patch.yml')],
|
|
97
|
+
['home-patch', path.join(home, 'cordis.patch.yml')],
|
|
98
|
+
]) {
|
|
99
|
+
if (!fs.existsSync(file)) continue;
|
|
100
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
101
|
+
const { value, error } = parseYaml(text, file);
|
|
102
|
+
if (error) warnings.push(error);
|
|
103
|
+
layers.push({ kind, name: kind, version: null, file, entries: Array.isArray(value) ? value : [], text });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Out-of-tree plugins: profile dependencies carrying a `dsh` field.
|
|
107
|
+
const packages = [];
|
|
108
|
+
for (const dep of Object.keys(manifest.dependencies ?? {})) {
|
|
109
|
+
const dir = resolveBundleDir(home, profileDir, dep);
|
|
110
|
+
const pkg = dir ? readJson(path.join(dir, 'package.json')) : null;
|
|
111
|
+
if (pkg?.dsh) packages.push({ name: dep, version: pkg.version ?? null, dir, dsh: pkg.dsh });
|
|
112
|
+
if (!dir) warnings.push(`dependency not installed: ${dep}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { home, profile: profileName, manifestFile, bundles, layers, packages, warnings };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { collectStatic, dshHome };
|
package/lib/model.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Model layer: pure functions over collector output. No IO here.
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Replay patch layers over an empty row list, recording per-row provenance.
|
|
8
|
+
*
|
|
9
|
+
* Patch entry shapes (verified against dsh-base/web-app bundle patches and
|
|
10
|
+
* dsh's loader semantics: id-targeted whole-config replacement, insert lists):
|
|
11
|
+
* - { insert: [row, ...] } append new rows
|
|
12
|
+
* - { id, config?, disabled?, ... } override an existing row by id
|
|
13
|
+
*
|
|
14
|
+
* @param layers output of collectStatic().layers
|
|
15
|
+
* @returns {{rows: Map<id, row>, orphans: [], provenance: Map<id, [event]>}}
|
|
16
|
+
* event: {layer, kind, action: 'insert'|'override', file, fields}
|
|
17
|
+
*/
|
|
18
|
+
function replayLayers(layers) {
|
|
19
|
+
const rows = new Map();
|
|
20
|
+
const provenance = new Map();
|
|
21
|
+
const orphans = []; // overrides targeting an id that does not exist (silently skipped by dsh)
|
|
22
|
+
|
|
23
|
+
const record = (id, event) => {
|
|
24
|
+
if (!provenance.has(id)) provenance.set(id, []);
|
|
25
|
+
provenance.get(id).push(event);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
for (const layer of layers) {
|
|
29
|
+
const meta = { layer: layer.name, kind: layer.kind, file: layer.file };
|
|
30
|
+
for (const entry of layer.entries) {
|
|
31
|
+
if (entry && Array.isArray(entry.insert)) {
|
|
32
|
+
for (const row of entry.insert) {
|
|
33
|
+
const id = row.id ?? row.name;
|
|
34
|
+
rows.set(id, { ...row, id });
|
|
35
|
+
record(id, { ...meta, action: 'insert', fields: Object.keys(row) });
|
|
36
|
+
}
|
|
37
|
+
} else if (entry && entry.id !== undefined) {
|
|
38
|
+
const { id, ...rest } = entry;
|
|
39
|
+
if (!rows.has(id)) {
|
|
40
|
+
orphans.push({ ...meta, id, fields: Object.keys(rest) });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// Loader semantics: whole-config replacement, not deep merge.
|
|
44
|
+
rows.set(id, { ...rows.get(id), ...rest });
|
|
45
|
+
record(id, { ...meta, action: 'override', fields: Object.keys(rest) });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { rows, provenance, orphans };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** F1: per-row layer attribution table. */
|
|
53
|
+
function attribute(staticData) {
|
|
54
|
+
const { rows, provenance, orphans } = replayLayers(staticData.layers);
|
|
55
|
+
const table = [...rows.values()].map((row) => {
|
|
56
|
+
const events = provenance.get(row.id) ?? [];
|
|
57
|
+
const origin = events.find((e) => e.action === 'insert') ?? null;
|
|
58
|
+
return {
|
|
59
|
+
id: row.id,
|
|
60
|
+
name: row.name ?? null,
|
|
61
|
+
disabled: row.disabled === true,
|
|
62
|
+
origin: origin ? { layer: origin.layer, kind: origin.kind } : null,
|
|
63
|
+
overrides: events.filter((e) => e.action === 'override')
|
|
64
|
+
.map((e) => ({ layer: e.layer, kind: e.kind, fields: e.fields })),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
return { rows: table, orphans, warnings: staticData.warnings };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** F3: rows written by more than one layer, with the winning writer last. */
|
|
71
|
+
function conflicts(staticData) {
|
|
72
|
+
const { provenance } = replayLayers(staticData.layers);
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const [id, events] of provenance) {
|
|
75
|
+
// A conflict needs two writers touching the same field set beyond the insert.
|
|
76
|
+
const writers = events.filter((e) => e.action === 'override');
|
|
77
|
+
if (writers.length === 0) continue;
|
|
78
|
+
const byField = new Map();
|
|
79
|
+
for (const e of events) {
|
|
80
|
+
for (const f of e.fields) {
|
|
81
|
+
if (f === 'id') continue;
|
|
82
|
+
if (!byField.has(f)) byField.set(f, []);
|
|
83
|
+
byField.get(f).push({ layer: e.layer, kind: e.kind, action: e.action });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const contested = [...byField.entries()].filter(([, w]) => w.length > 1);
|
|
87
|
+
if (contested.length === 0) continue;
|
|
88
|
+
out.push({
|
|
89
|
+
id,
|
|
90
|
+
fields: contested.map(([field, writers]) => ({
|
|
91
|
+
field,
|
|
92
|
+
writers,
|
|
93
|
+
winner: writers[writers.length - 1].layer,
|
|
94
|
+
})),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** F2: declared (static replay) vs actual (dump-config) diff. */
|
|
101
|
+
function diff(staticData, dumpData) {
|
|
102
|
+
const declared = replayLayers(staticData.layers);
|
|
103
|
+
const actualById = new Map(dumpData.rows.map((r) => [r.id, r]));
|
|
104
|
+
|
|
105
|
+
const missingFromActual = []; // declared but dsh dropped it
|
|
106
|
+
const missingFromDeclared = []; // in the boot tree but no static layer declares it
|
|
107
|
+
const disabledMismatch = [];
|
|
108
|
+
|
|
109
|
+
for (const [id, row] of declared.rows) {
|
|
110
|
+
const actual = actualById.get(id);
|
|
111
|
+
if (!actual) {
|
|
112
|
+
missingFromActual.push({ id, name: row.name ?? null });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if ((row.disabled === true) !== actual.disabled) {
|
|
116
|
+
disabledMismatch.push({ id, declared: row.disabled === true, actual: actual.disabled });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const row of dumpData.rows) {
|
|
120
|
+
if (!declared.rows.has(row.id)) {
|
|
121
|
+
missingFromDeclared.push({ id: row.id, name: row.name, provenance: row.provenance });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Installed out-of-tree plugins whose bundle patch rows never made it in.
|
|
126
|
+
const inactivePackages = staticData.packages
|
|
127
|
+
.filter((p) => p.dsh?.bundle)
|
|
128
|
+
.filter((p) => ![...declared.rows.values()].some((r) => r.name === p.name)
|
|
129
|
+
&& !dumpData.rows.some((r) => r.name === p.name))
|
|
130
|
+
.map((p) => ({ name: p.name, version: p.version }));
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
missingFromActual,
|
|
134
|
+
missingFromDeclared,
|
|
135
|
+
disabledMismatch,
|
|
136
|
+
orphanOverrides: declared.orphans,
|
|
137
|
+
inactivePackages,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** F9: content-addressed snapshot of the effective composition. */
|
|
142
|
+
function snapshot(staticData, dumpData) {
|
|
143
|
+
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
144
|
+
return {
|
|
145
|
+
schema: 'dsh-xray/snapshot@1',
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
profile: staticData.profile,
|
|
148
|
+
bundles: staticData.layers
|
|
149
|
+
.filter((l) => l.kind === 'bundle')
|
|
150
|
+
.map((l) => ({ name: l.name, version: l.version, patchHash: sha(l.text) })),
|
|
151
|
+
patches: staticData.layers
|
|
152
|
+
.filter((l) => l.kind !== 'bundle')
|
|
153
|
+
.map((l) => ({ kind: l.kind, file: l.file, hash: sha(l.text) })),
|
|
154
|
+
packages: staticData.packages.map((p) => ({ name: p.name, version: p.version })),
|
|
155
|
+
composedHash: dumpData ? sha(dumpData.raw) : null,
|
|
156
|
+
rowCount: dumpData ? dumpData.rows.length : null,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { replayLayers, attribute, conflicts, diff, snapshot };
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-xray",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "X-ray for your DeepSeek Harness — see what's actually loaded, why, and what it costs you.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dsh-xray": "bin/xray.js"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./lib/index.js",
|
|
9
12
|
"./package.json": "./package.json",
|
|
@@ -11,10 +14,17 @@
|
|
|
11
14
|
},
|
|
12
15
|
"files": [
|
|
13
16
|
"lib",
|
|
17
|
+
"bin",
|
|
14
18
|
"cordis.patch.yml",
|
|
15
19
|
"README.md",
|
|
16
20
|
"README.zh.md"
|
|
17
21
|
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"yaml": "^2.6.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test tests/model.spec.js"
|
|
27
|
+
},
|
|
18
28
|
"keywords": [
|
|
19
29
|
"dsh-plugin",
|
|
20
30
|
"deepseek-harness",
|