dsh-xray 0.10.2 → 0.11.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/README.md +34 -12
- package/bin/xray.js +65 -6
- package/docs/README.zh.md +34 -12
- package/lib/collect/static.js +19 -5
- package/lib/compare.js +7 -1
- package/lib/index.js +2 -0
- package/lib/model.js +113 -20
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -79,19 +79,20 @@ The same data flows through three surfaces: the **X-Ray tab** (native GUI), the
|
|
|
79
79
|
|
|
80
80
|
```sh
|
|
81
81
|
npx dsh-xray attribute # which layer introduced each row, and who patched it since
|
|
82
|
-
npx dsh-xray conflicts #
|
|
82
|
+
npx dsh-xray conflicts # contested fields with per-writer evidence: file:line, value, winner
|
|
83
83
|
npx dsh-xray diff # declared (static layers) vs actual (dump-config) tree
|
|
84
|
-
npx dsh-xray snapshot # content-addressed lockfile
|
|
85
|
-
npx dsh-xray deps [svc] # service dependency graph: providers, consumers, disable-cascade
|
|
84
|
+
npx dsh-xray snapshot # content-addressed lockfile; --against <lock> reports drift, exits 1
|
|
85
|
+
npx dsh-xray deps [svc] # service dependency graph: providers, consumers, transitive disable-cascade
|
|
86
86
|
npx dsh-xray health # plugin lifecycle health: failed fibers, pending injects, transitions
|
|
87
87
|
npx dsh-xray cost # context cost: prompt sections + tool schemas, estimated tokens
|
|
88
88
|
npx dsh-xray shadow # services provided by multiple plugins
|
|
89
|
+
npx dsh-xray verify # declared (static) rows ↔ runtime registry, exits 1 on mismatch
|
|
89
90
|
npx dsh-xray audit # static scan of out-of-tree plugins for sensitive touchpoints
|
|
90
91
|
```
|
|
91
92
|
|
|
92
93
|

|
|
93
94
|
|
|
94
|
-
`attribute`, `conflicts`, and `snapshot` are fully static — they work even when dsh cannot boot. All commands take `--profile <name>` (default `web`) and `--json`; `diff`
|
|
95
|
+
`attribute`, `conflicts`, and `snapshot` are fully static — they work even when dsh cannot boot. All commands take `--profile <name>` (default `web`) and `--json`; every JSON payload carries a versioned `schema` field (`dsh-xray/<view>@1`) so machine consumers detect shape changes instead of guessing. Exit codes slot into CI: `diff` (trees disagree), `health` (unhealthy plugin), `snapshot --against <lock>` (composition drifted), `shadow` (multi-provider service) and `verify` (declared ↔ runtime mismatch) all exit `1`.
|
|
95
96
|
|
|
96
97
|
---
|
|
97
98
|
|
|
@@ -113,13 +114,20 @@ Installed-but-inactive, uninstalled-but-lingering patch rows — including patch
|
|
|
113
114
|
Plugins patching the same config row, and which one silently wins.
|
|
114
115
|
|
|
115
116
|
### 📸 Composition Snapshot
|
|
116
|
-
Export the effective composition as a lockfile;
|
|
117
|
+
Export the effective composition as a lockfile; `snapshot --against <lock>` reports drift — bundle version / patch content / package changes — and exits `1`.
|
|
117
118
|
|
|
118
119
|
</td>
|
|
119
120
|
<td width="50%">
|
|
120
121
|
|
|
121
122
|
### 🌐 Service Dependency Graph
|
|
122
|
-
Who provides and consumes each service — and the **disable-cascade
|
|
123
|
+
Who provides and consumes each service — and the full **transitive** disable-cascade: not just direct consumers, but everything downstream of the services they re-provide.
|
|
124
|
+
|
|
125
|
+
```console
|
|
126
|
+
$ npx dsh-xray deps
|
|
127
|
+
# disable-cascade (transitive consumers of each provider):
|
|
128
|
+
Loader → 5 plugin(s): AgentPresets, ClientModuleRegistry, Hmr, …
|
|
129
|
+
TimerService → 1 plugin(s): Hmr
|
|
130
|
+
```
|
|
123
131
|
|
|
124
132
|
### 💊 Runtime Health
|
|
125
133
|
Per-plugin fiber lifecycle state, startup failures, pending injects, transition history.
|
|
@@ -140,7 +148,7 @@ Heuristic static scan of out-of-tree plugins: network egress, shell, filesystem,
|
|
|
140
148
|
<img src="./assets/section-agent.svg" width="100%" alt="Agent Tool">
|
|
141
149
|
</p>
|
|
142
150
|
|
|
143
|
-
Mounted in the tree, dsh-xray registers an `xray_composition` tool (`view: summary | deps | health | cost | shadow`), so an agent can answer:
|
|
151
|
+
Mounted in the tree, dsh-xray registers an `xray_composition` tool (`view: summary | deps | health | cost | shadow | skills | requests`), so an agent can answer:
|
|
144
152
|
|
|
145
153
|
> *"What capabilities do I have?" / "What plugin provides X?" / "Why is Y unavailable?"*
|
|
146
154
|
|
|
@@ -160,6 +168,18 @@ Mounted in the tree, dsh-xray registers an `xray_composition` tool (`view: summa
|
|
|
160
168
|
- The entry endpoint returns composition-layer text only, **never session messages**
|
|
161
169
|
- See [SECURITY.md](./SECURITY.md)
|
|
162
170
|
|
|
171
|
+
## Analysis modes
|
|
172
|
+
|
|
173
|
+
Every result names its trust boundary:
|
|
174
|
+
|
|
175
|
+
| Mode | Commands | Boundary |
|
|
176
|
+
| --- | --- | --- |
|
|
177
|
+
| **Static** | `attribute`, `conflicts`, `snapshot` | Exact replay of the on-disk layer stack; works even when dsh cannot boot. Cannot observe runtime behavior. |
|
|
178
|
+
| **Static + spawn** | `diff` | Replays the layers, then spawns `dsh --dump-config` to compare declared vs. actual. |
|
|
179
|
+
| **Static + runtime** | `verify` | Joins both sides: declared rows that never mounted, disabled rows still running, runtime-only plugins, snapshot staleness. |
|
|
180
|
+
| **Runtime** | `deps`, `health`, `cost`, `shadow`, tab, `/xray` panel, agent tool | Observed from the running composition (`$DSH_HOME/xray/runtime.json`); valid for the current session only. Token counts are estimates (~4 chars/token) unless you open the entry text and count. |
|
|
181
|
+
| **Heuristic** | `audit` | Pattern scan over source text; false positives and negatives are expected. A flag means "this pattern appears", never "this plugin is malicious". |
|
|
182
|
+
|
|
163
183
|
---
|
|
164
184
|
|
|
165
185
|
<p align="center">
|
|
@@ -192,12 +212,13 @@ npx dsh-xray health # reads the runtime snapshot
|
|
|
192
212
|
|
|
193
213
|
Uninstall: `dsh plugin --profile web remove dsh-xray`.
|
|
194
214
|
|
|
195
|
-
| Command |
|
|
215
|
+
| Command | Exit code |
|
|
196
216
|
| --- | --- |
|
|
197
|
-
| `diff` |
|
|
198
|
-
| `health` |
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
217
|
+
| `diff` | `1` when the trees disagree |
|
|
218
|
+
| `health` | `1` when any plugin is unhealthy |
|
|
219
|
+
| `snapshot --against <lock>` | `1` when the composition drifted |
|
|
220
|
+
| `shadow` | `1` when any service has multiple providers |
|
|
221
|
+
| `verify` | `1` on any declared ↔ runtime mismatch |
|
|
201
222
|
|
|
202
223
|
---
|
|
203
224
|
|
|
@@ -216,6 +237,7 @@ Diagnostic imaging for a running composition — complementary to [dsh-doctor](h
|
|
|
216
237
|
| Declared vs. actual diff | 🔍 Inspection |
|
|
217
238
|
| Conflict detection | 🔍 Inspection |
|
|
218
239
|
| Composition snapshot | 📦 Export |
|
|
240
|
+
| Static ↔ runtime verification | 🔍 Inspection |
|
|
219
241
|
| Service dependency graph | 🌐 Runtime |
|
|
220
242
|
| Runtime health | 🌐 Runtime |
|
|
221
243
|
| Service shadowing | 🌐 Runtime |
|
package/bin/xray.js
CHANGED
|
@@ -58,13 +58,24 @@ function cmdConflicts(args) {
|
|
|
58
58
|
const data = collectStatic(args.profile);
|
|
59
59
|
const result = model.conflicts(data);
|
|
60
60
|
if (args.json) return console.log(JSON.stringify(result, null, 2));
|
|
61
|
-
if (!result.length)
|
|
62
|
-
|
|
61
|
+
if (!result.conflicts.length)
|
|
62
|
+
return console.log('no contested rows: every field has a single writer');
|
|
63
|
+
const path = require('node:path');
|
|
64
|
+
const show = (v) => {
|
|
65
|
+
const s = JSON.stringify(v) ?? '(unset)';
|
|
66
|
+
return s.length > 48 ? `${s.slice(0, 45)}…` : s;
|
|
67
|
+
};
|
|
68
|
+
for (const c of result.conflicts) {
|
|
63
69
|
console.log(`${c.id}`);
|
|
64
70
|
for (const f of c.fields) {
|
|
65
|
-
console.log(
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
console.log(` .${f.field} — winner: ${f.winner} (last writer wins)`);
|
|
72
|
+
for (const w of f.writers) {
|
|
73
|
+
const loc = w.file
|
|
74
|
+
? ` ${path.relative(data.home, w.file)}${w.line ? `:${w.line}` : ''}`
|
|
75
|
+
: '';
|
|
76
|
+
console.log(` ${pad(w.layer, 24)} ${pad(w.action, 9)} ${pad(show(w.value), 48)}${loc}`);
|
|
77
|
+
}
|
|
78
|
+
console.log(` effective: ${show(f.effective)}`);
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
81
|
}
|
|
@@ -268,6 +279,52 @@ function cmdShadow(args) {
|
|
|
268
279
|
panelHint(args);
|
|
269
280
|
}
|
|
270
281
|
|
|
282
|
+
function cmdVerify(args) {
|
|
283
|
+
const data = collectStatic(args.profile);
|
|
284
|
+
const snap = readRuntimeSnapshot();
|
|
285
|
+
const fs = require('node:fs');
|
|
286
|
+
let staticMtimeMs = null;
|
|
287
|
+
for (const l of data.layers) {
|
|
288
|
+
try {
|
|
289
|
+
staticMtimeMs = Math.max(staticMtimeMs ?? 0, fs.statSync(l.file).mtimeMs);
|
|
290
|
+
} catch {
|
|
291
|
+
/* unreadable layer file: leave staleness unknown for it */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const result = model.verify(data, snap, { staticMtimeMs });
|
|
295
|
+
if (args.json) return console.log(JSON.stringify(result, null, 2));
|
|
296
|
+
|
|
297
|
+
const declared = result.matched.length + result.declaredNotRunning.length;
|
|
298
|
+
console.log(`runtime snapshot captured ${result.capturedAt}`);
|
|
299
|
+
if (result.stale) {
|
|
300
|
+
console.log(
|
|
301
|
+
'! static layers changed after this snapshot — restart dsh or wait for the next refresh',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const mark = result.declaredNotRunning.length ? '⚠' : '✓';
|
|
305
|
+
console.log(
|
|
306
|
+
`${mark} declared enabled plugins observed at runtime: ${result.matched.length}/${declared}`,
|
|
307
|
+
);
|
|
308
|
+
for (const r of result.declaredNotRunning) console.log(` missing: ${r.id} (${r.name})`);
|
|
309
|
+
if (result.disabledButRunning.length) {
|
|
310
|
+
console.log(`✗ disabled but running (${result.disabledButRunning.length}):`);
|
|
311
|
+
for (const r of result.disabledButRunning) {
|
|
312
|
+
console.log(` ${r.id} (${r.name}) — runtime: ${r.runtime}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (result.undeclaredRuntime.length) {
|
|
316
|
+
console.log(
|
|
317
|
+
`+ ${result.undeclaredRuntime.length} runtime-only plugin(s) (programmatic subplugins are normal): ${result.undeclaredRuntime.slice(0, 6).join(', ')}${result.undeclaredRuntime.length > 6 ? ', …' : ''}`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (result.unsatisfied.length) {
|
|
321
|
+
console.log(`! ${result.unsatisfied.length} unsatisfied inject(s):`);
|
|
322
|
+
for (const u of result.unsatisfied) console.log(` ${u.plugin} wants ${u.service}`);
|
|
323
|
+
}
|
|
324
|
+
if (result.declaredNotRunning.length || result.disabledButRunning.length) process.exitCode = 1;
|
|
325
|
+
panelHint(args);
|
|
326
|
+
}
|
|
327
|
+
|
|
271
328
|
function cmdAudit(args) {
|
|
272
329
|
const { collectAudit } = require('../lib/collect/audit.js');
|
|
273
330
|
const data = collectStatic(args.profile);
|
|
@@ -298,6 +355,7 @@ const commands = {
|
|
|
298
355
|
health: cmdHealth,
|
|
299
356
|
cost: cmdCost,
|
|
300
357
|
shadow: cmdShadow,
|
|
358
|
+
verify: cmdVerify,
|
|
301
359
|
audit: cmdAudit,
|
|
302
360
|
};
|
|
303
361
|
|
|
@@ -317,9 +375,10 @@ Commands:
|
|
|
317
375
|
health plugin lifecycle health from the live runtime snapshot
|
|
318
376
|
cost estimated context-token cost of each model-facing tool schema
|
|
319
377
|
shadow services provided by multiple plugins, and per-plugin registrations
|
|
378
|
+
verify declared (static) rows reconciled against the runtime registry
|
|
320
379
|
audit static scan of out-of-tree plugins for sensitive touchpoints
|
|
321
380
|
|
|
322
|
-
deps/health/cost/shadow need the plugin mounted: dsh plugin --profile web add dsh-xray`);
|
|
381
|
+
deps/health/cost/shadow/verify need the plugin mounted: dsh plugin --profile web add dsh-xray`);
|
|
323
382
|
process.exit(args._[0] ? 2 : 0);
|
|
324
383
|
}
|
|
325
384
|
try {
|
package/docs/README.zh.md
CHANGED
|
@@ -78,19 +78,20 @@ cost 视图回答一个别的工具都不问的问题:**这段上下文是谁放
|
|
|
78
78
|
|
|
79
79
|
```sh
|
|
80
80
|
npx dsh-xray attribute # 每一行由哪层引入、之后被谁 patch 过
|
|
81
|
-
npx dsh-xray conflicts #
|
|
81
|
+
npx dsh-xray conflicts # 有争议的字段,带每个写者的证据:file:line、写入值、赢家
|
|
82
82
|
npx dsh-xray diff # 声明(静态层)vs 实际(dump-config)组合树
|
|
83
|
-
npx dsh-xray snapshot #
|
|
84
|
-
npx dsh-xray deps [svc] #
|
|
83
|
+
npx dsh-xray snapshot # 内容寻址 lockfile;--against <lock> 对比漂移,漂移时退出码 1
|
|
84
|
+
npx dsh-xray deps [svc] # 服务依赖图:提供者、消费者、传递性停用级联
|
|
85
85
|
npx dsh-xray health # 插件生命周期健康:失败 fiber、等待中的注入、状态迁移史
|
|
86
86
|
npx dsh-xray cost # 上下文成本:prompt sections + 工具 schema 的估算 token 占用
|
|
87
87
|
npx dsh-xray shadow # 被多个插件同时提供的服务
|
|
88
|
+
npx dsh-xray verify # 声明(静态)行 ↔ 运行时注册表对账,不一致退出码 1
|
|
88
89
|
npx dsh-xray audit # 对 out-of-tree 插件做敏感触点静态扫描
|
|
89
90
|
```
|
|
90
91
|
|
|
91
92
|

|
|
92
93
|
|
|
93
|
-
`attribute`、`conflicts`、`snapshot` 是纯静态的——dsh 起不来时照样能跑。所有命令支持 `--profile <name>`(默认 `web`)和 `--json
|
|
94
|
+
`attribute`、`conflicts`、`snapshot` 是纯静态的——dsh 起不来时照样能跑。所有命令支持 `--profile <name>`(默认 `web`)和 `--json`;所有 JSON 输出都带版本化的 `schema` 字段(`dsh-xray/<view>@1`),机器消费方据此识别结构变化而不必猜。退出码可直接进 CI:`diff`(两树不一致)、`health`(有插件不健康)、`snapshot --against <lock>`(组合漂移)、`shadow`(服务被多方提供)、`verify`(声明与运行时不符)均返回 `1`。
|
|
94
95
|
|
|
95
96
|
---
|
|
96
97
|
|
|
@@ -112,13 +113,20 @@ npx dsh-xray audit # 对 out-of-tree 插件做敏感触点静态扫描
|
|
|
112
113
|
多个插件 patch 同一配置行时,谁静默赢了。
|
|
113
114
|
|
|
114
115
|
### 📸 组合快照
|
|
115
|
-
把当前生效组合导出为 lockfile
|
|
116
|
+
把当前生效组合导出为 lockfile;`snapshot --against <lock>` 报告漂移——bundle 版本 / patch 内容 / 包变化——并退出码 `1`。
|
|
116
117
|
|
|
117
118
|
</td>
|
|
118
119
|
<td width="50%">
|
|
119
120
|
|
|
120
121
|
### 🌐 服务依赖图
|
|
121
|
-
|
|
122
|
+
每个服务谁提供、谁消费——以及**传递性**停用级联:不只是直接消费者,还包括它们再提供的服务的所有下游。
|
|
123
|
+
|
|
124
|
+
```console
|
|
125
|
+
$ npx dsh-xray deps
|
|
126
|
+
# disable-cascade (transitive consumers of each provider):
|
|
127
|
+
Loader → 5 plugin(s): AgentPresets, ClientModuleRegistry, Hmr, …
|
|
128
|
+
TimerService → 1 plugin(s): Hmr
|
|
129
|
+
```
|
|
122
130
|
|
|
123
131
|
### 💊 运行时健康
|
|
124
132
|
每个插件的 fiber 生命周期状态、启动失败、等待中的注入、状态迁移史。
|
|
@@ -139,7 +147,7 @@ npx dsh-xray audit # 对 out-of-tree 插件做敏感触点静态扫描
|
|
|
139
147
|
<img src="../assets/section-agent.svg" width="100%" alt="Agent 工具">
|
|
140
148
|
</p>
|
|
141
149
|
|
|
142
|
-
挂载进树后,dsh-xray 注册 `xray_composition` 工具(`view: summary | deps | health | cost | shadow`),agent 可以自答:
|
|
150
|
+
挂载进树后,dsh-xray 注册 `xray_composition` 工具(`view: summary | deps | health | cost | shadow | skills | requests`),agent 可以自答:
|
|
143
151
|
|
|
144
152
|
> *"我有哪些能力?" / "哪个插件提供 X?" / "为什么 Y 不可用?"*
|
|
145
153
|
|
|
@@ -159,6 +167,18 @@ npx dsh-xray audit # 对 out-of-tree 插件做敏感触点静态扫描
|
|
|
159
167
|
- entry 端点只返回组合层文本,**绝不返回会话消息**
|
|
160
168
|
- 详见 [SECURITY.md](../SECURITY.md)
|
|
161
169
|
|
|
170
|
+
## 分析模式
|
|
171
|
+
|
|
172
|
+
每类结果都有明确的可信度边界:
|
|
173
|
+
|
|
174
|
+
| 模式 | 命令 | 边界 |
|
|
175
|
+
| --- | --- | --- |
|
|
176
|
+
| **静态** | `attribute`、`conflicts`、`snapshot` | 对磁盘上层栈的精确重放;dsh 起不来也能跑。观测不到运行时行为。 |
|
|
177
|
+
| **静态 + 外呼** | `diff` | 重放层栈后,再起一个 `dsh --dump-config` 对比声明与实际。 |
|
|
178
|
+
| **静态 + 运行时** | `verify` | 两侧对账:声明了却没挂载的行、禁用了还在跑的行、只存在于运行时的插件、快照过期。 |
|
|
179
|
+
| **运行时** | `deps`、`health`、`cost`、`shadow`、标签页、`/xray` 面板、agent 工具 | 观测自运行中的组合树(`$DSH_HOME/xray/runtime.json`),只对当前会话有效。token 为估算值(约 4 字符/token),除非你打开条目原文自己数。 |
|
|
180
|
+
| **启发式** | `audit` | 对源码文本的模式扫描;可能误报漏报。命中只表示"该模式出现在代码里",绝不等于"该插件是恶意的"。 |
|
|
181
|
+
|
|
162
182
|
---
|
|
163
183
|
|
|
164
184
|
<p align="center">
|
|
@@ -191,12 +211,13 @@ npx dsh-xray health # 读取运行时快照
|
|
|
191
211
|
|
|
192
212
|
卸载:`dsh plugin --profile web remove dsh-xray`。
|
|
193
213
|
|
|
194
|
-
| 命令 |
|
|
214
|
+
| 命令 | 退出码 |
|
|
195
215
|
| --- | --- |
|
|
196
|
-
| `diff` |
|
|
197
|
-
| `health` |
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
216
|
+
| `diff` | 两棵树不一致时 `1` |
|
|
217
|
+
| `health` | 有插件不健康时 `1` |
|
|
218
|
+
| `snapshot --against <lock>` | 组合漂移时 `1` |
|
|
219
|
+
| `shadow` | 服务被多方提供时 `1` |
|
|
220
|
+
| `verify` | 声明与运行时不符时 `1` |
|
|
200
221
|
|
|
201
222
|
---
|
|
202
223
|
|
|
@@ -215,6 +236,7 @@ npx dsh-xray health # 读取运行时快照
|
|
|
215
236
|
| 声明 vs 实际 diff | 🔍 检视 |
|
|
216
237
|
| 冲突检测 | 🔍 检视 |
|
|
217
238
|
| 组合快照 | 📦 导出 |
|
|
239
|
+
| 静态 ↔ 运行时对账 | 🔍 检视 |
|
|
218
240
|
| 服务依赖图 | 🌐 运行时 |
|
|
219
241
|
| 运行时健康 | 🌐 运行时 |
|
|
220
242
|
| 服务遮蔽 | 🌐 运行时 |
|
package/lib/collect/static.js
CHANGED
|
@@ -16,9 +16,20 @@ const jsTag = {
|
|
|
16
16
|
|
|
17
17
|
function parseYaml(text, file) {
|
|
18
18
|
try {
|
|
19
|
-
|
|
19
|
+
const lineCounter = new YAML.LineCounter();
|
|
20
|
+
const doc = YAML.parseDocument(text, { customTags: [jsTag], lineCounter });
|
|
21
|
+
if (doc.errors.length) {
|
|
22
|
+
return { value: null, lines: [], error: `${file}: ${doc.errors[0].message}` };
|
|
23
|
+
}
|
|
24
|
+
// One source line per top-level patch entry, for evidence output.
|
|
25
|
+
const lines = YAML.isSeq(doc.contents)
|
|
26
|
+
? doc.contents.items.map((item) =>
|
|
27
|
+
item?.range ? lineCounter.linePos(item.range[0]).line : null,
|
|
28
|
+
)
|
|
29
|
+
: [];
|
|
30
|
+
return { value: doc.toJS(), lines, error: null };
|
|
20
31
|
} catch (err) {
|
|
21
|
-
return { value: null, error: `${file}: ${err.message}` };
|
|
32
|
+
return { value: null, lines: [], error: `${file}: ${err.message}` };
|
|
22
33
|
}
|
|
23
34
|
}
|
|
24
35
|
|
|
@@ -79,7 +90,7 @@ function collectStatic(profileName) {
|
|
|
79
90
|
}
|
|
80
91
|
const file = path.join(dir, rel);
|
|
81
92
|
const text = fs.readFileSync(file, 'utf8');
|
|
82
|
-
const { value, error } = parseYaml(text, file);
|
|
93
|
+
const { value, lines, error } = parseYaml(text, file);
|
|
83
94
|
if (error) warnings.push(error);
|
|
84
95
|
layers.push({
|
|
85
96
|
kind: 'bundle',
|
|
@@ -87,6 +98,7 @@ function collectStatic(profileName) {
|
|
|
87
98
|
version: pkg.version ?? null,
|
|
88
99
|
file,
|
|
89
100
|
entries: Array.isArray(value) ? value : [],
|
|
101
|
+
lines,
|
|
90
102
|
text,
|
|
91
103
|
});
|
|
92
104
|
}
|
|
@@ -97,7 +109,7 @@ function collectStatic(profileName) {
|
|
|
97
109
|
]) {
|
|
98
110
|
if (!fs.existsSync(file)) continue;
|
|
99
111
|
const text = fs.readFileSync(file, 'utf8');
|
|
100
|
-
const { value, error } = parseYaml(text, file);
|
|
112
|
+
const { value, lines, error } = parseYaml(text, file);
|
|
101
113
|
if (error) warnings.push(error);
|
|
102
114
|
layers.push({
|
|
103
115
|
kind,
|
|
@@ -105,6 +117,7 @@ function collectStatic(profileName) {
|
|
|
105
117
|
version: null,
|
|
106
118
|
file,
|
|
107
119
|
entries: Array.isArray(value) ? value : [],
|
|
120
|
+
lines,
|
|
108
121
|
text,
|
|
109
122
|
});
|
|
110
123
|
}
|
|
@@ -131,7 +144,7 @@ function collectStatic(profileName) {
|
|
|
131
144
|
continue;
|
|
132
145
|
}
|
|
133
146
|
const text = fs.readFileSync(file, 'utf8');
|
|
134
|
-
const { value, error } = parseYaml(text, file);
|
|
147
|
+
const { value, lines, error } = parseYaml(text, file);
|
|
135
148
|
if (error) warnings.push(error);
|
|
136
149
|
layers.push({
|
|
137
150
|
kind: 'repository',
|
|
@@ -139,6 +152,7 @@ function collectStatic(profileName) {
|
|
|
139
152
|
version: pkg.version ?? null,
|
|
140
153
|
file,
|
|
141
154
|
entries: Array.isArray(value) ? value : [],
|
|
155
|
+
lines,
|
|
142
156
|
text,
|
|
143
157
|
});
|
|
144
158
|
}
|
package/lib/compare.js
CHANGED
|
@@ -68,7 +68,13 @@ function compareSnapshots(saved, current) {
|
|
|
68
68
|
!changes.patches.length &&
|
|
69
69
|
!changes.packages.length &&
|
|
70
70
|
!changes.composed;
|
|
71
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
schema: 'dsh-xray/snapshot-diff@1',
|
|
73
|
+
identical,
|
|
74
|
+
savedAt: saved.createdAt,
|
|
75
|
+
profile: saved.profile,
|
|
76
|
+
changes,
|
|
77
|
+
};
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
module.exports = { compareSnapshots };
|
package/lib/index.js
CHANGED
|
@@ -177,6 +177,7 @@ function apply(ctx) {
|
|
|
177
177
|
summary: () => {
|
|
178
178
|
const snap = freshSnap();
|
|
179
179
|
return {
|
|
180
|
+
schema: 'dsh-xray/summary@1',
|
|
180
181
|
plugins: snap.plugins.length,
|
|
181
182
|
unhealthy: health(snap).unhealthy.length,
|
|
182
183
|
services: Object.keys(serviceGraph(snap).services).length,
|
|
@@ -257,6 +258,7 @@ function apply(ctx) {
|
|
|
257
258
|
if (args.view === 'skills') return skillCost(snap);
|
|
258
259
|
if (args.view === 'requests') return requestLedger(snap);
|
|
259
260
|
return {
|
|
261
|
+
schema: 'dsh-xray/summary@1',
|
|
260
262
|
plugins: snap.plugins.length,
|
|
261
263
|
unhealthy: health(snap).unhealthy.length,
|
|
262
264
|
services: Object.keys(serviceGraph(snap).services).length,
|
package/lib/model.js
CHANGED
|
@@ -12,7 +12,7 @@ const crypto = require('node:crypto');
|
|
|
12
12
|
*
|
|
13
13
|
* @param layers output of collectStatic().layers
|
|
14
14
|
* @returns {{rows: Map<id, row>, orphans: [], provenance: Map<id, [event]>}}
|
|
15
|
-
* event: {layer, kind, action: 'insert'|'override',
|
|
15
|
+
* event: {layer, kind, file, line, action: 'insert'|'override', fields, values, before?}
|
|
16
16
|
*/
|
|
17
17
|
function replayLayers(layers) {
|
|
18
18
|
const rows = new Map();
|
|
@@ -25,25 +25,39 @@ function replayLayers(layers) {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
for (const layer of layers) {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
layer.entries.forEach((entry, index) => {
|
|
29
|
+
const meta = {
|
|
30
|
+
layer: layer.name,
|
|
31
|
+
kind: layer.kind,
|
|
32
|
+
file: layer.file,
|
|
33
|
+
line: layer.lines?.[index] ?? null,
|
|
34
|
+
};
|
|
30
35
|
if (entry && Array.isArray(entry.insert)) {
|
|
31
36
|
for (const row of entry.insert) {
|
|
32
37
|
const id = row.id ?? row.name;
|
|
33
38
|
rows.set(id, { ...row, id });
|
|
34
|
-
record(id, { ...meta, action: 'insert', fields: Object.keys(row) });
|
|
39
|
+
record(id, { ...meta, action: 'insert', fields: Object.keys(row), values: { ...row } });
|
|
35
40
|
}
|
|
36
41
|
} else if (entry && entry.id !== undefined) {
|
|
37
42
|
const { id, ...rest } = entry;
|
|
38
|
-
|
|
43
|
+
const current = rows.get(id);
|
|
44
|
+
if (!current) {
|
|
39
45
|
orphans.push({ ...meta, id, fields: Object.keys(rest) });
|
|
40
|
-
|
|
46
|
+
return;
|
|
41
47
|
}
|
|
48
|
+
const before = {};
|
|
49
|
+
for (const f of Object.keys(rest)) before[f] = current[f];
|
|
42
50
|
// Loader semantics: whole-config replacement, not deep merge.
|
|
43
|
-
rows.set(id, { ...
|
|
44
|
-
record(id, {
|
|
51
|
+
rows.set(id, { ...current, ...rest });
|
|
52
|
+
record(id, {
|
|
53
|
+
...meta,
|
|
54
|
+
action: 'override',
|
|
55
|
+
fields: Object.keys(rest),
|
|
56
|
+
values: { ...rest },
|
|
57
|
+
before,
|
|
58
|
+
});
|
|
45
59
|
}
|
|
46
|
-
}
|
|
60
|
+
});
|
|
47
61
|
}
|
|
48
62
|
return { rows, provenance, orphans };
|
|
49
63
|
}
|
|
@@ -58,18 +72,26 @@ function attribute(staticData) {
|
|
|
58
72
|
id: row.id,
|
|
59
73
|
name: row.name ?? null,
|
|
60
74
|
disabled: row.disabled === true,
|
|
61
|
-
origin: origin
|
|
75
|
+
origin: origin
|
|
76
|
+
? { layer: origin.layer, kind: origin.kind, file: origin.file, line: origin.line }
|
|
77
|
+
: null,
|
|
62
78
|
overrides: events
|
|
63
79
|
.filter((e) => e.action === 'override')
|
|
64
|
-
.map((e) => ({
|
|
80
|
+
.map((e) => ({
|
|
81
|
+
layer: e.layer,
|
|
82
|
+
kind: e.kind,
|
|
83
|
+
file: e.file,
|
|
84
|
+
line: e.line,
|
|
85
|
+
fields: e.fields,
|
|
86
|
+
})),
|
|
65
87
|
};
|
|
66
88
|
});
|
|
67
|
-
return { rows: table, orphans, warnings: staticData.warnings };
|
|
89
|
+
return { schema: 'dsh-xray/attribute@1', rows: table, orphans, warnings: staticData.warnings };
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
/** F3: rows written by more than one layer, with the winning writer last. */
|
|
71
93
|
function conflicts(staticData) {
|
|
72
|
-
const { provenance } = replayLayers(staticData.layers);
|
|
94
|
+
const { rows, provenance } = replayLayers(staticData.layers);
|
|
73
95
|
const out = [];
|
|
74
96
|
for (const [id, events] of provenance) {
|
|
75
97
|
// A conflict needs two writers touching the same field set beyond the insert.
|
|
@@ -80,7 +102,14 @@ function conflicts(staticData) {
|
|
|
80
102
|
for (const f of e.fields) {
|
|
81
103
|
if (f === 'id') continue;
|
|
82
104
|
if (!byField.has(f)) byField.set(f, []);
|
|
83
|
-
byField.get(f).push({
|
|
105
|
+
byField.get(f).push({
|
|
106
|
+
layer: e.layer,
|
|
107
|
+
kind: e.kind,
|
|
108
|
+
action: e.action,
|
|
109
|
+
file: e.file,
|
|
110
|
+
line: e.line,
|
|
111
|
+
value: e.values?.[f],
|
|
112
|
+
});
|
|
84
113
|
}
|
|
85
114
|
}
|
|
86
115
|
const contested = [...byField.entries()].filter(([, w]) => w.length > 1);
|
|
@@ -91,10 +120,15 @@ function conflicts(staticData) {
|
|
|
91
120
|
field,
|
|
92
121
|
writers,
|
|
93
122
|
winner: writers[writers.length - 1].layer,
|
|
123
|
+
effective: rows.get(id)?.[field],
|
|
94
124
|
})),
|
|
95
125
|
});
|
|
96
126
|
}
|
|
97
|
-
return
|
|
127
|
+
return {
|
|
128
|
+
schema: 'dsh-xray/conflicts@1',
|
|
129
|
+
semantics: 'per-field last-writer-wins, layers applied in load order',
|
|
130
|
+
conflicts: out,
|
|
131
|
+
};
|
|
98
132
|
}
|
|
99
133
|
|
|
100
134
|
/** F2: declared (static replay) vs actual (dump-config) diff. */
|
|
@@ -133,6 +167,7 @@ function diff(staticData, dumpData) {
|
|
|
133
167
|
.map((p) => ({ name: p.name, version: p.version }));
|
|
134
168
|
|
|
135
169
|
return {
|
|
170
|
+
schema: 'dsh-xray/diff@1',
|
|
136
171
|
missingFromActual,
|
|
137
172
|
missingFromDeclared,
|
|
138
173
|
disabledMismatch,
|
|
@@ -214,7 +249,7 @@ function serviceGraph(snap) {
|
|
|
214
249
|
}
|
|
215
250
|
}
|
|
216
251
|
}
|
|
217
|
-
return { services, cascade, unsatisfied };
|
|
252
|
+
return { schema: 'dsh-xray/deps@1', services, cascade, unsatisfied };
|
|
218
253
|
}
|
|
219
254
|
|
|
220
255
|
/** F5: unhealthy plugins from fiber states and transition history. */
|
|
@@ -237,6 +272,7 @@ function health(snap) {
|
|
|
237
272
|
else healthy.push(entry);
|
|
238
273
|
}
|
|
239
274
|
return {
|
|
275
|
+
schema: 'dsh-xray/health@1',
|
|
240
276
|
healthy: healthy.map((p) => p.name),
|
|
241
277
|
waiting: waiting.map((p) => ({
|
|
242
278
|
name: p.name,
|
|
@@ -253,7 +289,7 @@ function shadowing(snap) {
|
|
|
253
289
|
// secondary source. Cordis rejects same-layer duplicate tools itself, so
|
|
254
290
|
// what we surface is cross-source duplication: one service claimed by
|
|
255
291
|
// multiple plugins (isolation scopes make this legal — and invisible).
|
|
256
|
-
const out = { services: [], registrars: [] };
|
|
292
|
+
const out = { schema: 'dsh-xray/shadow@1', services: [], registrars: [] };
|
|
257
293
|
const providersByService = new Map();
|
|
258
294
|
for (const s of snap.services ?? []) {
|
|
259
295
|
if (!providersByService.has(s.name)) providersByService.set(s.name, []);
|
|
@@ -317,6 +353,7 @@ function contextCost(snap) {
|
|
|
317
353
|
}));
|
|
318
354
|
|
|
319
355
|
return {
|
|
356
|
+
schema: 'dsh-xray/cost@1',
|
|
320
357
|
totalTokens: total,
|
|
321
358
|
toolTokens,
|
|
322
359
|
sectionTokens,
|
|
@@ -345,7 +382,10 @@ function contextCost(snap) {
|
|
|
345
382
|
* ecosystem's skill managers: xray prices, it never toggles. */
|
|
346
383
|
function skillCost(snap) {
|
|
347
384
|
const observed = snap.skillCatalog;
|
|
348
|
-
|
|
385
|
+
const schema = 'dsh-xray/skills@1';
|
|
386
|
+
if (!observed) {
|
|
387
|
+
return { schema, available: false, skills: [], totals: null, capturedAt: snap.capturedAt };
|
|
388
|
+
}
|
|
349
389
|
const rows = observed.skills
|
|
350
390
|
.slice()
|
|
351
391
|
.sort((a, b) => b.catalogTokens - a.catalogTokens)
|
|
@@ -360,6 +400,7 @@ function skillCost(snap) {
|
|
|
360
400
|
const invocable = rows.filter((s) => s.modelInvocable);
|
|
361
401
|
const catalogEntryTokens = invocable.reduce((sum, s) => sum + s.catalogTokens, 0);
|
|
362
402
|
return {
|
|
403
|
+
schema,
|
|
363
404
|
available: true,
|
|
364
405
|
skills: rows,
|
|
365
406
|
totals: {
|
|
@@ -382,8 +423,9 @@ function skillCost(snap) {
|
|
|
382
423
|
* cost view lacks. */
|
|
383
424
|
function requestLedger(snap) {
|
|
384
425
|
const observed = snap.requestLedger;
|
|
426
|
+
const schema = 'dsh-xray/requests@1';
|
|
385
427
|
if (!observed || Object.keys(observed).length === 0)
|
|
386
|
-
return { available: false, sessions: [], capturedAt: snap.capturedAt };
|
|
428
|
+
return { schema, available: false, sessions: [], capturedAt: snap.capturedAt };
|
|
387
429
|
const sessions = Object.entries(observed)
|
|
388
430
|
.map(([sessionId, entries]) => ({
|
|
389
431
|
sessionId,
|
|
@@ -409,7 +451,57 @@ function requestLedger(snap) {
|
|
|
409
451
|
})),
|
|
410
452
|
}))
|
|
411
453
|
.sort((a, b) => b.lastAt - a.lastAt);
|
|
412
|
-
return { available: true, sessions, capturedAt: snap.capturedAt };
|
|
454
|
+
return { schema, available: true, sessions, capturedAt: snap.capturedAt };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** F12: reconcile the declared (static) composition against the runtime registry. */
|
|
458
|
+
function verify(staticData, snap, { staticMtimeMs = null } = {}) {
|
|
459
|
+
const { rows } = replayLayers(staticData.layers);
|
|
460
|
+
const norm = (s) =>
|
|
461
|
+
String(s ?? '')
|
|
462
|
+
.toLowerCase()
|
|
463
|
+
.replace(/[^a-z0-9]/g, '');
|
|
464
|
+
// Registry names are plugin callback names (PluginTimer); rows carry the
|
|
465
|
+
// loader names (plugin-timer). Normalized comparison bridges the two — a
|
|
466
|
+
// heuristic, so runtime-only names are reported, never treated as errors.
|
|
467
|
+
const runtimeByNorm = new Map();
|
|
468
|
+
for (const p of snap.plugins) {
|
|
469
|
+
if (p.name) runtimeByNorm.set(norm(p.name), p.name);
|
|
470
|
+
}
|
|
471
|
+
const matchedNorms = new Set();
|
|
472
|
+
const matched = [];
|
|
473
|
+
const declaredNotRunning = [];
|
|
474
|
+
const disabledButRunning = [];
|
|
475
|
+
for (const row of rows.values()) {
|
|
476
|
+
const candidates = [norm(row.name), norm(row.id)].filter(Boolean);
|
|
477
|
+
const key = candidates.find((k) => runtimeByNorm.has(k)) ?? null;
|
|
478
|
+
if (key) matchedNorms.add(key);
|
|
479
|
+
const entry = { id: row.id, name: row.name ?? null };
|
|
480
|
+
if (row.disabled === true) {
|
|
481
|
+
if (key) disabledButRunning.push({ ...entry, runtime: runtimeByNorm.get(key) });
|
|
482
|
+
} else if (key) {
|
|
483
|
+
matched.push({ ...entry, runtime: runtimeByNorm.get(key) });
|
|
484
|
+
} else {
|
|
485
|
+
declaredNotRunning.push(entry);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const undeclaredRuntime = [...runtimeByNorm.entries()]
|
|
489
|
+
.filter(([k]) => !matchedNorms.has(k))
|
|
490
|
+
.map(([, name]) => name)
|
|
491
|
+
.sort();
|
|
492
|
+
const capturedMs = Date.parse(snap.capturedAt ?? '');
|
|
493
|
+
const stale =
|
|
494
|
+
staticMtimeMs != null && Number.isFinite(capturedMs) ? staticMtimeMs > capturedMs : null;
|
|
495
|
+
return {
|
|
496
|
+
schema: 'dsh-xray/verify@1',
|
|
497
|
+
capturedAt: snap.capturedAt ?? null,
|
|
498
|
+
stale,
|
|
499
|
+
matched,
|
|
500
|
+
declaredNotRunning,
|
|
501
|
+
disabledButRunning,
|
|
502
|
+
undeclaredRuntime,
|
|
503
|
+
unsatisfied: serviceGraph(snap).unsatisfied,
|
|
504
|
+
};
|
|
413
505
|
}
|
|
414
506
|
|
|
415
507
|
module.exports = {
|
|
@@ -424,4 +516,5 @@ module.exports = {
|
|
|
424
516
|
contextCost,
|
|
425
517
|
skillCost,
|
|
426
518
|
requestLedger,
|
|
519
|
+
verify,
|
|
427
520
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-xray",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "X-ray for your DeepSeek Harness — diagnostics for what's actually loaded, why, and what it costs: per-plugin context-tax attribution, per-request token ledger, skill catalog pricing, dependency cascades.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"dependency-graph",
|
|
49
49
|
"observability",
|
|
50
50
|
"audit",
|
|
51
|
-
"
|
|
51
|
+
"capability-scan",
|
|
52
52
|
"token-cost",
|
|
53
53
|
"lockfile",
|
|
54
54
|
"troubleshooting",
|