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 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 # rows whose fields have multiple writers, and who wins
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 of the effective composition
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
  ![dsh-xray demo](./docs/demo.svg)
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` and `health` exit `1` on drift/unhealth, so they slot into CI.
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; reproduce it elsewhere, diff against it later.
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**: exactly which dependents go down if you disable X.
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 | Behavior |
215
+ | Command | Exit code |
196
216
  | --- | --- |
197
- | `diff` | Exits `1` when the trees disagree |
198
- | `health` | Exits `1` when any plugin is unhealthy |
199
- | `attribute`, `conflicts`, `snapshot` | Fully static — work even when dsh cannot start |
200
- | `deps`, `health` | Read runtime snapshot at `$DSH_HOME/xray/runtime.json` |
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) return console.log('no contested rows: every field has a single writer');
62
- for (const c of result) {
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
- ` .${f.field}: ${f.writers.map((w) => w.layer).join(' → ')} (winner: ${f.winner})`,
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 # 当前生效组合的内容寻址 lockfile
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
  ![dsh-xray 演示](./demo.svg)
92
93
 
93
- `attribute`、`conflicts`、`snapshot` 是纯静态的——dsh 起不来时照样能跑。所有命令支持 `--profile <name>`(默认 `web`)和 `--json`;`diff` `health` 在漂移/不健康时退出码 `1`,可直接进 CI。
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
- 每个服务谁提供、谁消费——以及**停用级联**:停用 X 会连带瘫掉哪些依赖方。
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` | 两棵树不一致时退出码 `1` |
197
- | `health` | 有插件不健康时退出码 `1` |
198
- | `attribute`、`conflicts`、`snapshot` | 纯静态——dsh 起不来时照样能跑 |
199
- | `deps`、`health` | 读取 `$DSH_HOME/xray/runtime.json` 运行时快照 |
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
  | 服务遮蔽 | 🌐 运行时 |
@@ -16,9 +16,20 @@ const jsTag = {
16
16
 
17
17
  function parseYaml(text, file) {
18
18
  try {
19
- return { value: YAML.parse(text, { customTags: [jsTag] }), error: null };
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 { identical, savedAt: saved.createdAt, profile: saved.profile, changes };
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', file, fields}
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
- const meta = { layer: layer.name, kind: layer.kind, file: layer.file };
29
- for (const entry of layer.entries) {
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
- if (!rows.has(id)) {
43
+ const current = rows.get(id);
44
+ if (!current) {
39
45
  orphans.push({ ...meta, id, fields: Object.keys(rest) });
40
- continue;
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, { ...rows.get(id), ...rest });
44
- record(id, { ...meta, action: 'override', fields: Object.keys(rest) });
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 ? { layer: origin.layer, kind: origin.kind } : null,
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) => ({ layer: e.layer, kind: e.kind, fields: e.fields })),
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({ layer: e.layer, kind: e.kind, action: e.action });
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 out;
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
- if (!observed) return { available: false, skills: [], totals: null, capturedAt: snap.capturedAt };
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.10.2",
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
- "security-scan",
51
+ "capability-scan",
52
52
  "token-cost",
53
53
  "lockfile",
54
54
  "troubleshooting",