dsh-xray 0.0.1 → 0.1.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.
package/README.md CHANGED
@@ -4,22 +4,39 @@ X-ray for your [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harnes
4
4
 
5
5
  [中文](./README.zh.md)
6
6
 
7
- > **Status: name reservation (0.0.1).** This version mounts as a no-op plugin. Features land starting at 0.1.0.
7
+ > **Status: 0.1.x — static imaging.** The CLI works today, even when dsh cannot boot. Runtime imaging (dependency graph, health, context cost) lands in 0.2.0.
8
8
 
9
9
  `dsh --dump-config` shows you the composed tree. The plugin panel shows you a flat list. Neither tells you *why* a plugin is there, *what breaks* if you disable it, or *what it silently costs you*. dsh-xray does.
10
10
 
11
- ## Planned capabilities
11
+ ## CLI
12
12
 
13
- Diagnostic imaging for a running composition — complementary to [dsh-doctor](https://www.npmjs.com/package/dsh-doctor) (rescue & recovery):
13
+ ```sh
14
+ npx dsh-xray attribute # which layer introduced each row, and who patched it since
15
+ npx dsh-xray conflicts # rows whose fields have multiple writers, and who wins
16
+ npx dsh-xray diff # declared (static layers) vs actual (dump-config) tree
17
+ npx dsh-xray snapshot # content-addressed lockfile of the effective composition
18
+ ```
19
+
20
+ All commands take `--profile <name>` (default `web`) and `--json`. `diff` exits `1` when the trees disagree — orphan patch rows silently skipped by dsh, installed-but-inactive plugins, disabled-state mismatches. `attribute`, `conflicts`, and `snapshot` are fully static: they work even when dsh cannot start.
21
+
22
+ ## Capabilities
23
+
24
+ Diagnostic imaging for a running composition — complementary to [dsh-doctor](https://www.npmjs.com/package/dsh-doctor) (rescue & recovery).
25
+
26
+ Shipped in 0.1.x:
14
27
 
15
28
  - **Layer attribution** — which layer introduced each active plugin: kernel bundle, profile dependency, `cordis.patch.yml` insert, or repository source
16
29
  - **Declared vs. actual diff** — installed-but-inactive, uninstalled-but-lingering patch rows
30
+ - **Conflict detection** — plugins patching the same config row, and which one silently wins
31
+ - **Composition snapshot** — export the effective composition as a lockfile; reproduce it elsewhere
32
+
33
+ Planned:
34
+
17
35
  - **Service dependency graph** — who `inject`s whose service; what cascades if you disable X
18
36
  - **Runtime health** — per-plugin scope state: activation failures, stacks, load time, HMR reloads
19
37
  - **Capability audit** — what installed plugins actually touch: network egress, shell, filesystem, env; permission diff across updates
20
- - **Conflict detection** — plugins patching the same config row or registering the same command/tool, and which one silently wins
38
+ - **Command/tool shadowing** — plugins registering the same command/tool, and which one silently wins
21
39
  - **Context cost** — tokens each plugin injects into agent context: tool schemas, prompt sections, skills
22
- - **Composition snapshot** — export the effective composition as a lockfile; reproduce it elsewhere
23
40
  - **Agent self-introspection** — expose the composition as a tool so agents know what capabilities they have
24
41
 
25
42
  ## Install
package/README.zh.md CHANGED
@@ -4,22 +4,39 @@
4
4
 
5
5
  [English](./README.md)
6
6
 
7
- > **状态:占名版(0.0.1)。** 此版本以 no-op 插件形式挂载,功能从 0.1.0 开始落地。
7
+ > **状态:0.1.x 静态成像。** CLI 现在就能用,即使 dsh 起不来。运行时成像(依赖图、健康状态、上下文成本)在 0.2.0 落地。
8
8
 
9
9
  `dsh --dump-config` 只给你原始组合树,插件面板只给你平铺列表。它们都不回答:这个插件*为什么*在这、停用它会*连带瘫掉什么*、它在*悄悄消耗什么*。dsh-xray 回答这些。
10
10
 
11
- ## 规划中的能力
11
+ ## CLI
12
12
 
13
- 对运行中组合树的诊断成像——与 [dsh-doctor](https://www.npmjs.com/package/dsh-doctor)(救援与恢复)互补:
13
+ ```sh
14
+ npx dsh-xray attribute # 每一行由哪层引入、之后被谁 patch 过
15
+ npx dsh-xray conflicts # 哪些行的字段有多个写者、最终谁赢
16
+ npx dsh-xray diff # 声明(静态层)vs 实际(dump-config)组合树
17
+ npx dsh-xray snapshot # 当前生效组合的内容寻址 lockfile
18
+ ```
19
+
20
+ 所有命令支持 `--profile <name>`(默认 `web`)和 `--json`。`diff` 在两棵树不一致时以退出码 `1` 结束——被 dsh 静默跳过的 orphan patch 行、装了但未生效的插件、disabled 状态不匹配。`attribute`、`conflicts`、`snapshot` 是纯静态的:dsh 起不来时照样能跑。
21
+
22
+ ## 能力
23
+
24
+ 对运行中组合树的诊断成像——与 [dsh-doctor](https://www.npmjs.com/package/dsh-doctor)(救援与恢复)互补。
25
+
26
+ 0.1.x 已交付:
14
27
 
15
28
  - **来源归因** — 每个活跃插件来自哪一层:内核 bundle / profile 依赖 / `cordis.patch.yml` insert / repository 源
16
29
  - **声明 vs 实际 diff** — 装了但没生效、卸了但残留 patch 行
30
+ - **冲突检测** — 多个插件 patch 同一配置行时,谁静默赢了
31
+ - **组合快照** — 把当前生效组合导出为 lockfile,异地一键复现
32
+
33
+ 规划中:
34
+
17
35
  - **服务依赖图** — 谁 `inject` 了谁的服务;停用 X 会级联影响什么
18
36
  - **运行时健康** — 每个插件的 scope 状态:激活失败与堆栈、加载耗时、HMR 重载次数
19
37
  - **能力审计** — 已装插件实际触碰什么:网络外发、shell、文件系统、环境变量;更新前后权限 diff
20
- - **冲突检测**多个插件 patch 同一配置行、注册同名命令/工具时,谁静默赢了
38
+ - **命令/工具重名检测**注册同名命令/工具时,谁静默覆盖谁
21
39
  - **上下文成本** — 每个插件往 agent 上下文注入多少 token:工具 schema、prompt 片段、skill 文档
22
- - **组合快照** — 把当前生效组合导出为 lockfile,异地一键复现
23
40
  - **Agent 自省** — 把组合信息暴露为 tool,让 agent 知道自己有哪些能力
24
41
 
25
42
  ## 安装
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/index.js CHANGED
@@ -5,13 +5,13 @@ const name = 'dsh-xray';
5
5
  /**
6
6
  * dsh-xray — X-ray for your DeepSeek Harness.
7
7
  *
8
- * v0.0.1 is a name reservation. It mounts cleanly as a no-op plugin and
9
- * logs its presence; introspection features land starting at 0.1.0:
10
- * layer attribution, declared-vs-actual diff, service dependency graph,
11
- * runtime health, capability audit, conflict detection, context cost.
8
+ * 0.1.x: static imaging ships in the CLI (bin/xray.js) layer attribution,
9
+ * declared-vs-actual diff, conflict detection, composition snapshot.
10
+ * This Cordis entry is a mount point; runtime imaging (dependency graph,
11
+ * health, context cost, agent self-introspection) lands here in 0.2.0.
12
12
  */
13
13
  function apply(ctx) {
14
- ctx.logger(name).info('dsh-xray 0.0.1 mounted (name reservation; features land in 0.1.0)');
14
+ ctx.logger(name).info('dsh-xray mounted; run `npx dsh-xray` for static composition imaging (runtime imaging lands in 0.2.0)');
15
15
  }
16
16
 
17
17
  module.exports = { name, apply };
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.1",
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",