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 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
@@ -1,5 +1,4 @@
1
- # dsh-xray bundle patch: mounts the xray plugin into the composed tree.
1
+ # dsh bundle patch: inserts this plugin into a profile's layer stack.
2
2
  - insert:
3
- id: dsh-xray
4
- name: dsh-xray
5
- config: {}
3
+ - id: dsh-xray
4
+ name: dsh-xray
@@ -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.1",
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",