dsh-log-contract 0.3.7 → 0.3.8

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
@@ -274,10 +274,16 @@ pnpm check && pnpm test # syntax check + 79 unit tests (incl. incident regres
274
274
  node scripts/check-local-fossils.mjs # scans ../ for backup-session-*.jsonl.zstd
275
275
  ```
276
276
 
277
- Known truth table: incident-repaired sessions PASS; `seqgap`/`corrupt`/
278
- `rewritten-230542` FAIL; `spliced-orphan` PASS (legal for the persistence layer —
279
- #3632's "consumer path deems it unreadable" is a different contract; this tool only
280
- guards the persistence contract layer, see the boundary note in
277
+ Known truth table (updated 2026-08-31 after 0.3.5 added I1, `spliced-orphan`
278
+ now FAILs; the old PASS row was stale):
279
+ - `2c3f87d4-corrupt` / `b7713ea1-seqgap` / `rewritten-230542` FAIL (seq gaps)
280
+ - `2c3f87d4-spliced-orphan` **FAIL (0.3.5+)** (T1/I1: invalid inbox splice + turn-null)
281
+ - `e61d70da-pre-markerfix-20260825` → FAIL (pre-fix sample: turn-null markers remain)
282
+ - `c2d05ce9-pre-cleansession-20260831` → PASS (3256-span replace marker is data-legal;
283
+ official foldSurface replays cleanly — see the plugin ledger)
284
+ The truth table tracks rule evolution (0.3.5's I1 flipped spliced-orphan PASS→FAIL);
285
+ "repaired sessions PASS" must be verified on rebuilt samples — `pre-` backups are
286
+ usually pre-fix bad samples. See the boundary note in
281
287
  [docs/CONTRACTS.md](docs/CONTRACTS.md)).
282
288
 
283
289
  ---
package/README.zh.md CHANGED
@@ -236,7 +236,19 @@ pnpm check && pnpm test # 语法检查 + 79 个单测(含事故回归用例
236
236
  node scripts/check-local-fossils.mjs # 扫描 ../ 下 backup-session-*.jsonl.zstd
237
237
  ```
238
238
 
239
- 已知真值表:事故修复后会话 PASS;`seqgap`/`corrupt`/`rewritten-230542` FAIL;`spliced-orphan` PASS(持久化层合法——#3632 的"消费路径判不可读"属于另一类契约,本工具只守护持久化契约层,见 [docs/CONTRACTS.md](docs/CONTRACTS.md) 边界说明)。
239
+ 已知真值表(2026-08-31 实测更新——0.3.5 加 I1 后,`spliced-orphan` 已可报错,旧表 PASS 过时):
240
+
241
+ | 化石 | 判定 | 违规 |
242
+ |---|---|---|
243
+ | `2c3f87d4-corrupt` | FAIL | S8/C1/T1/E2(seq 缺口 → 加载被拒) |
244
+ | `b7713ea1-seqgap` / `recorrupt` | FAIL | S8/C1/T1/E2/S9(seq 缺口/倒退) |
245
+ | `2c3f87d4-rewritten-230542` | FAIL | S8/C1/T1/E2/I1(重写引入缺口) |
246
+ | `2c3f87d4-spliced-orphan` | **FAIL(0.3.5+)** | T1/I1(inbox splice 无效 + turn-null)——旧表 PASS 已过时 |
247
+ | `e61d70da-pre-markerfix-20260825` | FAIL | T1×5(修复前样本:turn-null marker 残留,非「修复后」) |
248
+ | `c2d05ce9-pre-cleansession-20260831` | **PASS** | error 0(3256 跨度 replace marker 数据合规,官方 foldSurface 重放通过——见插件任务台账) |
249
+
250
+ > 说明:真值表随规则演进更新(0.3.5 新增 I1 后 spliced-orphan 从 PASS 变 FAIL);
251
+ > 「修复后会话 PASS」需用重建/修复后的样本验证,pre- 前缀备份多为修复前坏样本。
240
252
 
241
253
  ---
242
254
 
package/lib/checks.js CHANGED
@@ -296,6 +296,71 @@ export function finalFold(events) {
296
296
  }
297
297
  }
298
298
 
299
+ /**
300
+ * T3 —— step 节点 key 冲突(2026-09-02 · 1e99e1ff 白屏真正根因固化)。
301
+ *
302
+ * 客户端渲染消息列表 = 从事件流构建节点,节点 key = `data.turn:data.step`
303
+ * (React 列表 key)。同 turn 内两个 step/start 的 step 号相同 → key 冲突 →
304
+ * React 渲染死循环 → 白屏/不展示(1e99e1ff:seq 580034 step 95/1 vs 580037
305
+ * 编辑块 step 95/1;修复线 2026-09-02 全量扫描另发现 6924781d / 97786207 /
306
+ * 4b149a4a 同型冲突,均整块重编号修复)。
307
+ *
308
+ * 判定:扫 step/start,`data.turn:data.step` 组合重复 = error。
309
+ * - 无任何 step/start 的日志跳过(与 T1 同款宽松);
310
+ * - turn/step 为 null/undefined 的 step/start 不参与 key 判定(那是 T4 的域,
311
+ * 且 null 自身已是致命)。
312
+ *
313
+ * @param events - 行序事件流(`{event, lineNo}`)。
314
+ * @returns T3 违规列表。
315
+ */
316
+ export function stepKeyViolations(events) {
317
+ const out = []
318
+ if (!events.some(({ event }) => event.type === 'step/start')) return out
319
+ const seen = new Map() // `${turn}:${step}` → { seq, lineNo }
320
+ for (const { event, lineNo } of events) {
321
+ if (event.type !== 'step/start') continue
322
+ const turn = event.data?.turn
323
+ const step = event.data?.step
324
+ if (turn === null || turn === undefined || step === null || step === undefined) continue // T4 域
325
+ const key = `${String(turn)}:${String(step)}`
326
+ const prev = seen.get(key)
327
+ if (prev !== undefined) {
328
+ out.push(violation('T3', { seq: event.seq, lineNo, eventType: event.type }, `step 节点 key ${key} 冲突:seq ${prev.seq} 与 seq ${event.seq} 的 step/start 同 turn 同 step——客户端 React 渲染死循环白屏(1e99e1ff 事故;修复:同 turn 内 step 递增,不得复用;整块重编号)`))
329
+ } else {
330
+ seen.set(key, { seq: event.seq, lineNo })
331
+ }
332
+ }
333
+ return out
334
+ }
335
+
336
+ /**
337
+ * T4 —— step/消息本体 turn 缺失(null/undefined)(2026-09-01 · D8 1e99e1ff 固化)。
338
+ *
339
+ * 客户端渲染状态机对 turn=null 的 step/消息**无法归属任何 turn** → 渲染死循环 →
340
+ * 白屏「载入历史」(1e99e1ff seq 580037-580039:retrace 0.4.17 写的编辑块
341
+ * step/start+marker+step/end turn 全 null;修复线 check-null-turn.mjs 把 step
342
+ * 包裹/消息本体的 null-turn 判为致命)。
343
+ *
344
+ * 判定:`step/start|step/end|assistant/message` 的 `data.turn === null/undefined` = error。
345
+ * - user/message 天然不带 turn(坐标来自外层 step),不查;
346
+ * - assistant/chunk 坐标可能天然缺失,不查;
347
+ * - retrace/marker(neutralize 产物,ignorable)不查。
348
+ *
349
+ * @param events - 行序事件流(`{event, lineNo}`)。
350
+ * @returns T4 违规列表。
351
+ */
352
+ export function nullTurnStepViolations(events) {
353
+ const out = []
354
+ for (const { event, lineNo } of events) {
355
+ if (event.type !== 'step/start' && event.type !== 'step/end' && event.type !== 'assistant/message') continue
356
+ const turn = event.data?.turn
357
+ if (turn === null || turn === undefined) {
358
+ out.push(violation('T4', { seq: event.seq, lineNo, eventType: event.type }, `${event.type} 的 data.turn 为 ${String(turn)}(缺失)——客户端渲染状态机无法归属任何 turn → 渲染死循环白屏(D8 1e99e1ff;step/消息本体必须带真实 turn 号)`))
359
+ }
360
+ }
361
+ return out
362
+ }
363
+
299
364
  /**
300
365
  * P3 —— tool/call ↔ tool/result 配对完整性(考古任务书 B1)。
301
366
  * 每个 tool/call 的 `data.callId` 必须能在 tool/result 的
package/lib/contracts.js CHANGED
@@ -229,6 +229,22 @@ export const CONTRACT_RULES = [
229
229
  source: '@deepseek-ai/dsh-token-meter lib/index.js:634-650 (_estimateProviderAssistant,:645 belongs to another step)',
230
230
  description: 'token meter 重建 provider 输出时,逐条检查 assistant/message 的 sourceEventSeqs:指向 assistant/chunk 的引用必须与消息同 turn/step,且 seq 更早、不重复;跨 step 引用 → 官方抛 belongs to another step → 每次事件追加都重抛(consumedEvents 不前进)→ 刷屏压垮 host(2026-08-30 实测 526f1835 seq 936047 跨 step 7/8/9)。T1 只查 step 配对不查源引用,此条补盲区;修复用 fix --clip-crossstep。',
231
231
  },
232
+ {
233
+ id: 'T3',
234
+ title: 'step 节点 key 唯一(同 turn 内 step/start 的 step 号不得复用)',
235
+ layer: LAYER.ENGINE,
236
+ severity: SEVERITY.ERROR,
237
+ source: '复盘 2026-09-02 1e99e1ff 白屏(修复线 session-3f9e4f12):客户端渲染节点 key = turn:step,冲突 → React 渲染死循环;工具 tools/check-step-keys.mjs',
238
+ description: '客户端渲染消息列表从事件流构建节点,节点 key = data.turn:data.step。同 turn 内两个 step/start 的 step 号相同 → key 冲突 → React 渲染死循环 → 白屏/不展示(1e99e1ff:580034 step 95/1 vs 580037 编辑块 step 95/1;6924781d/97786207/4b149a4a 同型)。修复:同 turn 内 step 递增、整块重编号(含块内 chunk/tool/assistant)。',
239
+ },
240
+ {
241
+ id: 'T4',
242
+ title: 'step/消息本体 turn 缺失(null/undefined)→ 渲染死循环',
243
+ layer: LAYER.ENGINE,
244
+ severity: SEVERITY.ERROR,
245
+ source: '复盘 D8 1e99e1ff(2026-09-01):retrace 0.4.17 编辑块 turn:null;修复线 tools/check-null-turn.mjs 判致命',
246
+ description: '客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 → 白屏「载入历史」(1e99e1ff seq 580037-580039)。user/message 天然无 turn 不查;chunk 坐标可缺失不查。step/消息本体必须带真实 turn 号。',
247
+ },
232
248
  {
233
249
  id: 'P3',
234
250
  title: 'tool/call ↔ tool/result 配对完整性(考古 B1)',
package/lib/index.js CHANGED
@@ -9,5 +9,5 @@ export { validateSessionLog, resumeVerdict } from './validate.js';
9
9
  export { createPreWriter, preWriterFromLog } from './prewrite.js';
10
10
  export { repairSession, strictScanText, removeMarkersText, neutralizeMarkersText, clipCrossStepSourcesText, dropFailedTurnsText, trimLastMessagesText, trimLastMessagesByBudget, estimateTokensText, compactLastMessagesText, rebuildZstdText, tailRenumberText, neutralizeOrphanText, extractTurnText, keepRangesText } from './repair.js';
11
11
  export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
12
- export { tokenMeterViolations, tokenMeterSourceViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
12
+ export { tokenMeterViolations, tokenMeterSourceViolations, stepKeyViolations, nullTurnStepViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
13
13
  export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
package/lib/prewrite.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * 所有判定复用 `lib/checks.js`(与离线体检同一套逻辑),
18
18
  * 保证"体检看到的问题 = 写入前拦下的问题"。
19
19
  */
20
- import { envelopeViolations, engineViolations, finalFold, isSafeInt, pluginViolations, replaySurface, tokenMeterViolations, violation } from './checks.js';
20
+ import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, violation } from './checks.js';
21
21
 
22
22
  /** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
23
23
  function isKnownMarkerCandidate(event) {
@@ -98,6 +98,19 @@ export function createPreWriter(input = {}) {
98
98
  violations.push(t1);
99
99
  }
100
100
  }
101
+ // T3/T4 —— 渲染层(2026-09-02 1e99e1ff 白屏)。**error 级拒绝**:
102
+ // - T4:拟写事件(step/start|step/end|assistant/message)turn 缺失 → 客户端
103
+ // 渲染死循环白屏(D8),写入前直接拦下(防再犯:任何写 turn:null 的 marker);
104
+ // - T3:拟写事件引入 step 节点 key 冲突(同 turn 同 step 的 step/start 重复)→
105
+ // 同样拒绝。只判拟写事件自身(历史冲突归属离线 check 的 T3/T4 扫描)。
106
+ for (const v of nullTurnStepViolations(candidateEvents.map((event) => ({ event })))) {
107
+ if (v.seq !== lastCandidate?.seq) continue;
108
+ violations.push(v);
109
+ }
110
+ for (const v of stepKeyViolations(candidateEvents.map((event) => ({ event })))) {
111
+ if (v.seq !== lastCandidate?.seq) continue;
112
+ violations.push(v);
113
+ }
101
114
  const bySeverity = { error: 0, warning: 0, info: 0 };
102
115
  for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
103
116
  return {
package/lib/validate.js CHANGED
@@ -15,9 +15,11 @@ import {
15
15
  finalFold,
16
16
  inboxReplayViolations,
17
17
  isSafeInt,
18
+ nullTurnStepViolations,
18
19
  physicalOrderViolations,
19
20
  pluginViolations,
20
21
  replaySurface,
22
+ stepKeyViolations,
21
23
  violation,
22
24
  tokenMeterViolations,
23
25
  tokenMeterSourceViolations,
@@ -115,6 +117,11 @@ export function validateSessionLog(log, opts = {}) {
115
117
  violations.push(...tokenMeterViolations(events));
116
118
  violations.push(...tokenMeterSourceViolations(events));
117
119
 
120
+ // ── T3/T4 · 渲染层(复盘 2026-09-02 1e99e1ff 白屏):节点 key 唯一 + turn 缺失 ──
121
+ // 数据层"健康"≠ 客户端能渲染:check/官方加载/分页全绿仍可能白屏(节点 key 冲突)。
122
+ violations.push(...stepKeyViolations(events));
123
+ violations.push(...nullTurnStepViolations(events));
124
+
118
125
  // ── I1 · inbox seed 相对重放(fork 边界孤儿;交接书 L1)──────────────
119
126
  violations.push(...inboxReplayViolations(events, header));
120
127
 
@@ -190,9 +197,9 @@ export function validateSessionLog(log, opts = {}) {
190
197
  export function resumeVerdict(result) {
191
198
  const { ok, violations = [] } = result;
192
199
  // 三档各自的「阻断规则集」——按任务书 §L3 档位定义:
193
- // 可加载:结构层(PERSISTENCE/FRAMING)+ 引擎层非 I1/T1/T2 的 error;
200
+ // 可加载:结构层(PERSISTENCE/FRAMING)+ 引擎层非 I1/T1/T2/T3/T4 的 error;
194
201
  // 可继续:+ I1(inbox 重放);
195
- // 可压缩:+ T1/T2(token-meter 配对)。
202
+ // 可压缩:+ T1/T2/T3/T4(token-meter 配对 + 渲染层节点 key/turn)。
196
203
  const byId = {};
197
204
  for (const v of violations) {
198
205
  if (v.severity !== 'error') continue;
@@ -200,11 +207,11 @@ export function resumeVerdict(result) {
200
207
  }
201
208
  const has = (id) => (byId[id]?.length ?? 0) > 0;
202
209
 
203
- // 可加载阻断 = 除 I1/T1/T2 外的所有 error 违规(结构/信封/物理序/工具配对等)。
204
- // 注意:不能用 result.ok(它把 T1/T2/I1 也计为 error)——三档判定按任务书定义,
205
- // T1/T2 只影响「可压缩」档、I1 只影响「可继续」档。
210
+ // 可加载阻断 = 除 I1/T1/T2/T3/T4 外的所有 error 违规(结构/信封/物理序/工具配对等)。
211
+ // 注意:不能用 result.ok(它把 T1/T2/T3/T4/I1 也计为 error)——三档判定按任务书定义,
212
+ // T1/T2/T3/T4 只影响「可压缩」档、I1 只影响「可继续」档。
206
213
  const loadableBlockers = violations.filter(
207
- (v) => v.severity === 'error' && !['I1', 'T1', 'T2'].includes(v.id),
214
+ (v) => v.severity === 'error' && !['I1', 'T1', 'T2', 'T3', 'T4'].includes(v.id),
208
215
  ).map((v) => v.id);
209
216
  const loadable = loadableBlockers.length === 0;
210
217
 
@@ -212,9 +219,9 @@ export function resumeVerdict(result) {
212
219
  const resumable = loadable && !has('I1');
213
220
 
214
221
  const compactableBlockers = resumable
215
- ? (has('T1') || has('T2') ? ['T1', 'T2'].filter((id) => has(id)) : [])
222
+ ? (has('T1') || has('T2') || has('T3') || has('T4') ? ['T1', 'T2', 'T3', 'T4'].filter((id) => has(id)) : [])
216
223
  : [];
217
- const compactable = resumable && !has('T1') && !has('T2');
224
+ const compactable = resumable && !has('T1') && !has('T2') && !has('T3') && !has('T4');
218
225
 
219
226
  // 最差档位
220
227
  const verdict = !loadable ? 'broken' : !resumable ? 'loadable' : !compactable ? 'resumable' : 'compactable';
@@ -232,7 +239,7 @@ export function resumeVerdict(result) {
232
239
  violationsByTier: {
233
240
  structural: [...new Set(loadableBlockers)],
234
241
  inbox: has('I1') ? ['I1'] : [],
235
- tokenMeter: has('T1') || has('T2') ? ['T1', 'T2'].filter((id) => has(id)) : [],
242
+ tokenMeter: has('T1') || has('T2') || has('T3') || has('T4') ? ['T1', 'T2', 'T3', 'T4'].filter((id) => has(id)) : [],
236
243
  },
237
244
  };
238
245
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-log-contract",
3
3
  "description": "日志契约守护 — DSH session log contract guard: offline health check (CLI) + pre-write validation for DeepSeek Harness session logs",
4
- "version": "0.3.7",
4
+ "version": "0.3.8",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",