dsh-xray 0.4.1 → 0.5.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 CHANGED
@@ -12,6 +12,7 @@ function parseArgs(argv) {
12
12
  for (let i = 0; i < argv.length; i++) {
13
13
  const a = argv[i];
14
14
  if (a === '--profile' || a === '-p') args.profile = argv[++i];
15
+ else if (a === '--against') args.against = argv[++i];
15
16
  else if (a === '--json') args.json = true;
16
17
  else args._.push(a);
17
18
  }
@@ -118,7 +119,44 @@ function cmdDiff(args) {
118
119
  function cmdSnapshot(args) {
119
120
  const data = collectStatic(args.profile);
120
121
  const { dump } = tryDump(args.profile);
121
- console.log(JSON.stringify(model.snapshot(data, dump), null, 2));
122
+ const current = model.snapshot(data, dump);
123
+ const againstFile = args.against;
124
+ if (!againstFile) return console.log(JSON.stringify(current, null, 2));
125
+
126
+ const fs = require('node:fs');
127
+ const { compareSnapshots } = require('../lib/compare.js');
128
+ const saved = JSON.parse(fs.readFileSync(againstFile, 'utf8'));
129
+ const result = compareSnapshots(saved, current);
130
+ if (args.json) return console.log(JSON.stringify(result, null, 2));
131
+
132
+ if (result.identical) {
133
+ return console.log(`composition identical to snapshot from ${result.savedAt}`);
134
+ }
135
+ console.log(`composition drifted from snapshot (${result.savedAt}):`);
136
+ for (const b of result.changes.bundles) {
137
+ if (b.change === 'added') console.log(` bundle + ${b.name}@${b.version}`);
138
+ else if (b.change === 'removed') console.log(` bundle - ${b.name}`);
139
+ else
140
+ console.log(
141
+ ` bundle ~ ${b.name}: ${b.change} ${b.from.version ?? b.from.patchHash} → ${b.to.version ?? b.to.patchHash}`,
142
+ );
143
+ }
144
+ for (const p of result.changes.patches) {
145
+ console.log(
146
+ ` patch ${p.change === 'added' ? '+' : p.change === 'removed' ? '-' : '~'} ${p.kind}${p.change === 'content' ? `: ${p.from} → ${p.to}` : ''}`,
147
+ );
148
+ }
149
+ for (const p of result.changes.packages) {
150
+ if (p.change === 'added') console.log(` package + ${p.name}@${p.version}`);
151
+ else if (p.change === 'removed') console.log(` package - ${p.name}`);
152
+ else console.log(` package ~ ${p.name}: ${p.from} → ${p.to}`);
153
+ }
154
+ if (result.changes.composed) {
155
+ console.log(
156
+ ` composed tree hash: ${result.changes.composed.from} → ${result.changes.composed.to}`,
157
+ );
158
+ }
159
+ process.exitCode = 1;
122
160
  }
123
161
 
124
162
  function readRuntimeSnapshot() {
@@ -266,7 +304,7 @@ Commands:
266
304
  attribute which layer introduced each row, and who patched it since
267
305
  conflicts rows whose fields have multiple writers, and who wins
268
306
  diff declared (static layers) vs actual (dump-config) tree
269
- snapshot content-addressed lockfile of the effective composition
307
+ snapshot content-addressed lockfile; --against <file> diffs a saved one
270
308
  deps service dependency graph from the live runtime snapshot
271
309
  health plugin lifecycle health from the live runtime snapshot
272
310
  cost estimated context-token cost of each model-facing tool schema
@@ -109,6 +109,41 @@ function collectStatic(profileName) {
109
109
  });
110
110
  }
111
111
 
112
+ // Repository plugins: the third-party plugin-console mechanism mounts
113
+ // `.dsh-plugin` directories under the harness home; each carries its own
114
+ // patch file. Not dsh core — absence is normal.
115
+ const repoRoot = path.join(home, '.dsh-plugin');
116
+ if (fs.existsSync(repoRoot)) {
117
+ let entries = [];
118
+ try {
119
+ entries = fs.readdirSync(repoRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
120
+ } catch {
121
+ /* unreadable repository root: skip */
122
+ }
123
+ for (const e of entries) {
124
+ const dir = path.join(repoRoot, e.name);
125
+ const pkg = readJson(path.join(dir, 'package.json'));
126
+ const rel = pkg?.dsh?.bundle?.patch;
127
+ if (!rel) continue;
128
+ const file = path.join(dir, rel);
129
+ if (!fs.existsSync(file)) {
130
+ warnings.push(`repository plugin ${e.name}: patch missing (${rel})`);
131
+ continue;
132
+ }
133
+ const text = fs.readFileSync(file, 'utf8');
134
+ const { value, error } = parseYaml(text, file);
135
+ if (error) warnings.push(error);
136
+ layers.push({
137
+ kind: 'repository',
138
+ name: pkg.name ?? e.name,
139
+ version: pkg.version ?? null,
140
+ file,
141
+ entries: Array.isArray(value) ? value : [],
142
+ text,
143
+ });
144
+ }
145
+ }
146
+
112
147
  // Out-of-tree plugins: profile dependencies carrying a `dsh` field.
113
148
  const packages = [];
114
149
  for (const dep of Object.keys(manifest.dependencies ?? {})) {
package/lib/compare.js ADDED
@@ -0,0 +1,74 @@
1
+ // Snapshot comparison: current composition vs a saved lockfile.
2
+
3
+ function indexBy(list, key) {
4
+ const m = new Map();
5
+ for (const item of list ?? []) m.set(item[key], item);
6
+ return m;
7
+ }
8
+
9
+ /**
10
+ * F9b: compare a live snapshot against a saved one (`xray snapshot > lock.json`).
11
+ * Returns per-category changes; `identical` is true only when everything matches.
12
+ */
13
+ function compareSnapshots(saved, current) {
14
+ if (saved?.schema !== 'dsh-xray/snapshot@1') {
15
+ throw new Error(`not a dsh-xray snapshot: schema=${saved?.schema ?? 'missing'}`);
16
+ }
17
+ const changes = { bundles: [], patches: [], packages: [], composed: null };
18
+
19
+ const savedBundles = indexBy(saved.bundles, 'name');
20
+ const currentBundles = indexBy(current.bundles, 'name');
21
+ for (const [name, b] of currentBundles) {
22
+ const old = savedBundles.get(name);
23
+ if (!old) changes.bundles.push({ name, change: 'added', version: b.version });
24
+ else if (old.version !== b.version || old.patchHash !== b.patchHash) {
25
+ changes.bundles.push({
26
+ name,
27
+ change: old.version !== b.version ? 'version' : 'patch-content',
28
+ from: { version: old.version, patchHash: old.patchHash },
29
+ to: { version: b.version, patchHash: b.patchHash },
30
+ });
31
+ }
32
+ }
33
+ for (const name of savedBundles.keys()) {
34
+ if (!currentBundles.has(name)) changes.bundles.push({ name, change: 'removed' });
35
+ }
36
+
37
+ const savedPatches = indexBy(saved.patches, 'kind');
38
+ const currentPatches = indexBy(current.patches, 'kind');
39
+ for (const [kind, p] of currentPatches) {
40
+ const old = savedPatches.get(kind);
41
+ if (!old) changes.patches.push({ kind, change: 'added' });
42
+ else if (old.hash !== p.hash)
43
+ changes.patches.push({ kind, change: 'content', from: old.hash, to: p.hash });
44
+ }
45
+ for (const kind of savedPatches.keys()) {
46
+ if (!currentPatches.has(kind)) changes.patches.push({ kind, change: 'removed' });
47
+ }
48
+
49
+ const savedPkgs = indexBy(saved.packages, 'name');
50
+ const currentPkgs = indexBy(current.packages, 'name');
51
+ for (const [name, p] of currentPkgs) {
52
+ const old = savedPkgs.get(name);
53
+ if (!old) changes.packages.push({ name, change: 'added', version: p.version });
54
+ else if (old.version !== p.version) {
55
+ changes.packages.push({ name, change: 'version', from: old.version, to: p.version });
56
+ }
57
+ }
58
+ for (const name of savedPkgs.keys()) {
59
+ if (!currentPkgs.has(name)) changes.packages.push({ name, change: 'removed' });
60
+ }
61
+
62
+ if (saved.composedHash && current.composedHash && saved.composedHash !== current.composedHash) {
63
+ changes.composed = { from: saved.composedHash, to: current.composedHash };
64
+ }
65
+
66
+ const identical =
67
+ !changes.bundles.length &&
68
+ !changes.patches.length &&
69
+ !changes.packages.length &&
70
+ !changes.composed;
71
+ return { identical, savedAt: saved.createdAt, profile: saved.profile, changes };
72
+ }
73
+
74
+ module.exports = { compareSnapshots };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-xray",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "X-ray for your DeepSeek Harness — see what's actually loaded, why, and what it costs you.",
5
5
  "repository": {
6
6
  "type": "git",