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 +22 -5
- package/README.zh.md +22 -5
- 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/index.js +5 -5
- package/lib/model.js +160 -0
- package/package.json +11 -1
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:
|
|
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
|
-
##
|
|
11
|
+
## CLI
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
- **
|
|
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
|
-
>
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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.
|
|
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",
|