dsh-log-contract 0.3.11 → 0.3.13

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/lib/checks.js CHANGED
@@ -7,11 +7,20 @@
7
7
  * 全部判定与 `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方实现同语义,
8
8
  * 每条违规都挂 `lib/contracts.js` 中的规则 id 与官方源码出处。
9
9
  */
10
- import { foldSurface, isJsonValue, isSurfaceEligibleType, KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session';
10
+ import { isSurfaceEligibleType } from '@deepseek-ai/dsh-session';
11
+ import { isJsonValue } from './compat.js';
12
+ import { currentVocabulary, currentFold, V0_EVENT_TYPES } from './vocab.js';
11
13
  import { ruleById } from './contracts.js';
12
14
 
13
15
  export const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result']);
16
+ /** v3 新增 `system/message`(官方 `SURFACE_EVENT_TYPES`,0.1.5 `lib/index.js:149-154`)。 */
17
+ export const MODERN_SURFACE_TYPES = new Set([...SURFACE_TYPES, 'system/message']);
14
18
  export const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks']);
19
+
20
+ /** 当前文件版本适用的 surface 候选类型集(v3 起含 system/message)。version = 被检文件版本。 */
21
+ export function currentSurfaceTypes(version) {
22
+ return version >= 3 ? MODERN_SURFACE_TYPES : SURFACE_TYPES;
23
+ }
15
24
  export const MARKER_PREFIXES = ['retrace', 'message-editor'];
16
25
 
17
26
  /** 构造一条违规记录。 */
@@ -35,15 +44,15 @@ export function isSafeInt(v) {
35
44
  return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
36
45
  }
37
46
 
38
- /** 事件信封 shape:seq/type/time/data(E1/E3/E4/E5/E6)。 */
39
- export function envelopeViolations(event, loc) {
47
+ /** 事件信封 shape:seq/type/time/data(E1/E3/E4/E5/E6)。version = 被检文件版本。 */
48
+ export function envelopeViolations(event, loc, version) {
40
49
  const out = [];
41
50
  if (!isSafeInt(event.seq)) {
42
51
  out.push(violation('E1', loc, `事件 seq 缺失或非法(${String(event.seq)}),必须为非负安全整数`));
43
52
  }
44
53
  if (typeof event.type !== 'string') {
45
54
  out.push(violation('E3', loc, `事件缺少 type 字符串(${String(event.type)})`));
46
- } else if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable !== true) {
55
+ } else if (!currentVocabulary(version).has(event.type) && event.ignorable !== true) {
47
56
  out.push(violation('E3', loc, `type "${event.type}" 不在已知词汇表内且未带 ignorable 标记(可能由更新版本的 harness 写入)`));
48
57
  }
49
58
  if (!isJsonValue(event.data)) {
@@ -112,7 +121,7 @@ export function messageShapeViolations(event, loc) {
112
121
  return out;
113
122
  }
114
123
 
115
- /** replace 操作数精确形状(镜像 isReplaceOp,lib/index.js:300-303)。 */
124
+ /** replace 操作数精确形状(镜像旧格式 isReplaceOp,lib/index.js:300-303)。 */
116
125
  export function isReplaceOp(op) {
117
126
  return (
118
127
  typeof op === 'object' && op !== null &&
@@ -122,6 +131,29 @@ export function isReplaceOp(op) {
122
131
  );
123
132
  }
124
133
 
134
+ /**
135
+ * 按**被检文件版本**归一化 replace 操作数到内部 `{start,end}`。
136
+ *
137
+ * 一手依据:官方 replace 字段在 v3 改名——旧格式(v0/v1/v2)`{op,start,end}`,
138
+ * v3 `{op,startSeq,endSeq}`(`dsh-session-format-v2-to-v3/lib/index.js:361-371`)。
139
+ * 用错形状会得 null,由调用方归因为 S4(真实违规)。
140
+ *
141
+ * @param {unknown} op 待归一化的 surfaceOp
142
+ * @returns {{start:number,end:number}|null}
143
+ */
144
+ export function normalizeReplaceOp(op, version) {
145
+ if (typeof op !== 'object' || op === null || Array.isArray(op)) return null;
146
+ if (Object.keys(op).length !== 3 || op.op !== 'replace') return null;
147
+ if (version >= 3) {
148
+ return Object.hasOwn(op, 'startSeq') && Object.hasOwn(op, 'endSeq') && isSafeInt(op.startSeq) && isSafeInt(op.endSeq)
149
+ ? { start: op.startSeq, end: op.endSeq }
150
+ : null;
151
+ }
152
+ return Object.hasOwn(op, 'start') && Object.hasOwn(op, 'end') && isSafeInt(op.start) && isSafeInt(op.end)
153
+ ? { start: op.start, end: op.end }
154
+ : null;
155
+ }
156
+
125
157
  /**
126
158
  * S9 —— 文件物理序 seq 单调(2026-08-30 事故固化;交接书 L2)。
127
159
  *
@@ -157,14 +189,14 @@ export function physicalOrderViolations(rows) {
157
189
  * 与官方同语义的 surface 增量重放,逐事件归因 S1–S7。
158
190
  * @param {Array<{event:object, lineNo?:number}>} events 按日志顺序的事件(带 loc 包装)
159
191
  */
160
- export function replaySurface(events) {
192
+ export function replaySurface(events, version) {
161
193
  const violations = [];
162
194
  const nodes = [];
163
195
  let replaceGeneration = 0;
164
196
 
165
197
  for (const { event, lineNo } of events) {
166
198
  const loc = { seq: event.seq, lineNo, eventType: event.type };
167
- const eligible = SURFACE_TYPES.has(event.type);
199
+ const eligible = currentSurfaceTypes(version).has(event.type);
168
200
  const op = event.surfaceOp;
169
201
  const src = event.sourceEventSeqs;
170
202
 
@@ -185,18 +217,19 @@ export function replaySurface(events) {
185
217
  continue;
186
218
  }
187
219
 
188
- if (!isReplaceOp(op)) {
189
- violations.push(violation('S4', loc, 'replace 操作数必须精确为 {op:"replace", start, end}(start/end 为非负安全整数)'));
220
+ const normalized = normalizeReplaceOp(op, version);
221
+ if (normalized === null) {
222
+ violations.push(violation('S4', loc, 'replace 操作数必须精确为 {op:"replace", start, end}(v0/v1/v2)或 {op:"replace", startSeq, endSeq}(v3),且端点为非负安全整数'));
190
223
  continue;
191
224
  }
192
- const startIdx = nodes.indexOf(op.start);
193
- const endIdx = nodes.indexOf(op.end);
225
+ const startIdx = nodes.indexOf(normalized.start);
226
+ const endIdx = nodes.indexOf(normalized.end);
194
227
  if (startIdx === -1 || endIdx === -1) {
195
- violations.push(violation('S4', loc, `replace 范围 ${op.start}..${op.end} 不在当前 surface 中(start 存在=${startIdx !== -1},end 存在=${endIdx !== -1})`));
228
+ violations.push(violation('S4', loc, `replace 范围 ${normalized.start}..${normalized.end} 不在当前 surface 中(start 存在=${startIdx !== -1},end 存在=${endIdx !== -1})`));
196
229
  continue;
197
230
  }
198
231
  if (startIdx > endIdx) {
199
- violations.push(violation('S4', loc, `replace start ${op.start}(index ${startIdx})在 end ${op.end}(index ${endIdx})之后`));
232
+ violations.push(violation('S4', loc, `replace start ${normalized.start}(index ${startIdx})在 end ${normalized.end}(index ${endIdx})之后`));
200
233
  continue;
201
234
  }
202
235
  const shadowedSeqs = nodes.slice(startIdx, endIdx + 1);
@@ -287,10 +320,19 @@ export function pluginViolations(event, loc) {
287
320
  return out;
288
321
  }
289
322
 
290
- /** 官方 foldSurface 终验;不抛返回折叠结果,抛则返回 { error }。 */
291
- export function finalFold(events) {
323
+ /** 折叠终验:**按文件版本**择路(1.3 第二半 + 2026-09-14 复核补强)。
324
+ *
325
+ * - v3 文件 → 运行时导出的官方 `foldSurface`(与运行时同语义);
326
+ * - v0/v1/v2 文件 → 本地 `legacyFoldSurface`(rc.7 `foldSurface` 逐条移植)。
327
+ *
328
+ * 为什么旧格式不能借用官方实现的 `violations` 字段:官方 `foldSurface` **只抛不报**,
329
+ * 旧口径下这里曾回退到 `replaySurface` 的 violations,但**没有任何调用方读它**
330
+ * (validate.js / prewrite.js 只看 `folded.error`)⇒ S8 在旧格式上永不触发——这正是
331
+ * 2026-09-14 六条失败里四条的真因。现在两条路径都是"抛错 = 拒绝"的同一种契约。 */
332
+ export function finalFold(events, version) {
333
+ const fold = currentFold(version);
292
334
  try {
293
- return { surface: foldSurface(events) };
335
+ return { surface: fold(events) };
294
336
  } catch (err) {
295
337
  return { error: err };
296
338
  }
@@ -405,11 +447,11 @@ function isKnownIgnorableType(type) {
405
447
  if (KNOWN_IGNORABLE_CONSUMERS.has(type)) return true
406
448
  return type.startsWith('message-editor/') || type.startsWith('retrace/')
407
449
  }
408
- export function ignorableTypeViolations(events) {
450
+ export function ignorableTypeViolations(events, version) {
409
451
  const out = []
410
452
  for (const { event, lineNo } of events) {
411
453
  if (typeof event?.type !== 'string' || event.ignorable !== true) continue
412
- if (KNOWN_SESSION_EVENT_TYPES.has(event.type)) continue
454
+ if (currentVocabulary(version).has(event.type)) continue
413
455
  if (isKnownIgnorableType(event.type)) continue
414
456
  out.push(violation('E7', { seq: event.seq, lineNo, eventType: event.type }, `type "${event.type}" 不在已知词汇表且带 ignorable 标记、无已知消费者——被读路径接纳但无人消费 = 静默垃圾(ignorable 后门;若你的插件消费它,登记到 KNOWN_IGNORABLE_CONSUMERS)`))
415
457
  }
@@ -685,20 +727,21 @@ export function deriveWireMessage(event) {
685
727
  * @param events - 展开后的完整事件流(含 chunk 展开)。
686
728
  * @returns 违规列表。
687
729
  */
688
- export function wireViolations(events) {
730
+ export function wireViolations(events, version) {
689
731
  const out = [];
690
732
  // 先折叠 surface(append 入列;replace 移除 [start..end] 并将 marker 自身入列)
691
733
  const nodes = [];
692
734
  const bySeq = new Map();
693
735
  for (const { event, lineNo } of events) {
694
736
  bySeq.set(event.seq, { event, lineNo });
695
- if (!SURFACE_TYPES.has(event.type)) continue;
737
+ if (!currentSurfaceTypes(version).has(event.type)) continue;
696
738
  const op = event.surfaceOp;
697
739
  if (op === 'append') {
698
740
  nodes.push(event.seq);
699
- } else if (op && isReplaceOp(op)) {
700
- const s = nodes.indexOf(op.start);
701
- const e = nodes.indexOf(op.end);
741
+ } else if (op !== undefined) {
742
+ const normalized = normalizeReplaceOp(op, version);
743
+ const s = normalized === null ? -1 : nodes.indexOf(normalized.start);
744
+ const e = normalized === null ? -1 : nodes.indexOf(normalized.end);
702
745
  if (s !== -1 && e !== -1 && s <= e) nodes.splice(s, e - s + 1, event.seq);
703
746
  else nodes.push(event.seq);
704
747
  }
@@ -740,3 +783,88 @@ export function wireViolations(events) {
740
783
  }
741
784
  return out;
742
785
  }
786
+
787
+ /**
788
+ * G · 迁移预检(payload 层 + 类型层)——**官方迁移会不会拒**,与"本工具能否读取/折叠"是
789
+ * **两个独立维度**(见 contracts.js 的 LAYER.MIGRATION 段)。
790
+ *
791
+ * 覆盖(每条都有一手出处,官方 `@0.1.5-rc.2`):
792
+ * - **G1** `subagent/descriptor.data.version !== 3`
793
+ * - **G2** 事件类型不在官方 v0 dispositions 内(含 `ignorable:true`)
794
+ * - **G3** `session/title` / `session/title-llm-request` 的 `messageSeqs`(`v0-to-v1/lib/index.js:2543-2556`)
795
+ *
796
+ * 仍未覆盖(由 `migrationVerdict().coverage.uncovered` 显式列出,**不许**当通过):
797
+ * `turn/start`(闭合/预期轮)、继承切点、`assistant/attempt` 配对、`stored log corrupt`、
798
+ * v0→v1 对其余事件的形状拒绝。
799
+ *
800
+ * **为什么 `turn/start` 不做(第五轮实测结论)**:官方那个状态机(`v0-to-v1:2270` 的
801
+ * `assertReleasedArtifactRelationships`)**不是**在原始 v0 事件上跑的——它由 **v1→v2**
802
+ * 以 `RELEASED_V2_RELATIONSHIP_EXTENSIONS` 调用在**变换后的 v1/v2 artifact** 上
803
+ * (`v1-to-v2/lib/index.js:104`),并带 `cut`(继承切点)处理。第五轮实测:在原始 v0 上照抄该
804
+ * 状态机会在**已 seed 的会话**上狂报(样本 `session-62c5b531`:v0→v1 官方并不以该规则拒绝,
805
+ * 而原始 v0 上会报 19 条),属"规则文本对、应用对象错"。要忠实复现必须先把 v0→v1→v2 的
806
+ * 变换做出来 ⇒ 记未覆盖。
807
+ *
808
+ * 范围:三条都只约束 **v0 源文件**(官方 v0→v1 迁移的规则;v1/v2 源走别的迁移代码)。
809
+ * severity 固定 warning、layer 固定 migration ⇒ 不进 ok / loadable / resumable / compactable。
810
+ *
811
+ * @param {Array<{event:object, lineNo?:number}>} events 已展开事件(带 loc 包装)
812
+ * @param {number} version 被检文件版本
813
+ */
814
+ export function migrationPrecheckViolations(events, version) {
815
+ const out = [];
816
+ if (version !== 0) return out;
817
+ for (const { event, lineNo } of events) {
818
+ if (!event || typeof event !== 'object') continue;
819
+ if (event.type === 'subagent/descriptor') {
820
+ const dv = event.data?.version;
821
+ if (dv !== 3) {
822
+ out.push(violation('G1', { seq: event.seq, lineNo, eventType: event.type },
823
+ `subagent/descriptor data.version=${JSON.stringify(dv)}(官方 v0→v1 迁移要求 === 3)—— 升级到当前格式时官方会拒绝:subagent/descriptor ${event.seq} uses unsupported descriptor version ${String(dv)}`));
824
+ }
825
+ }
826
+ if (typeof event.type === 'string' && !V0_EVENT_TYPES.has(event.type)) {
827
+ // 官方对"unknown historical event"一律拒(原文 even when ignorable);E3 的 ignorable
828
+ // 豁免只作用于**读取路径**,这里不改它,只在迁移维度表达。
829
+ out.push(violation('G2', { seq: event.seq, lineNo, eventType: event.type },
830
+ `type "${event.type}" 不在官方 v0 dispositions 内${event.ignorable === true ? '(即使 ignorable:true)' : ''}—— 官方迁移会拒:format v0 contains unknown historical event type`));
831
+ }
832
+ }
833
+ // ── G3 · session/title 系列 messageSeqs(官方 v0→v1 `assertTitleSources`,:2543-2556)──
834
+ // 判据:每个被引 seq 必须解析到更早的 `user/message`,且 `data.source.kind === 'user'`;
835
+ // 另 `session/title` 的 `source.kind === 'user'` ⟺ `messageSeqs` 空。
836
+ //
837
+ // ⚠️ 应用面说明(第五轮实测,2026-09-15):官方 `assertTitleSources` 与 `turn/start` 状态机
838
+ // 同在 `assertReleasedArtifactRelationships` 里,而该函数被 **v1→v2** 以
839
+ // `RELEASED_V2_RELATIONSHIP_EXTENSIONS` 调用在**变换后的 v1/v2 artifact** 上
840
+ // (`dsh-session-format-v1-to-v2/lib/index.js:104`),不是原始 v0 事件。
841
+ // 对 `messageSeqs` 这类"按 seq 索引 + 事件类型/source 判定"的引用,变换保序保类型
842
+ // ⇒ 在原始 v0 上判是**必要条件的近似**;实测 281 个真实 v0 上 **0 误报**(round5/rule-probe)。
843
+ const bySeq = new Map();
844
+ for (const { event } of events) if (event && typeof event === 'object') bySeq.set(event.seq, event);
845
+ for (const { event, lineNo } of events) {
846
+ if (!event || typeof event !== 'object') continue;
847
+ if (event.type !== 'session/title' && event.type !== 'session/title-llm-request') continue;
848
+ const seqs = event.data?.messageSeqs;
849
+ if (!Array.isArray(seqs)) {
850
+ out.push(violation('G3', { seq: event.seq, lineNo, eventType: event.type },
851
+ `${event.type} ${event.seq} 的 messageSeqs 必须是数组 —— 官方迁移会拒`));
852
+ continue;
853
+ }
854
+ if (event.type === 'session/title') {
855
+ const kind = event.data?.source?.kind;
856
+ if ((seqs.length === 0) !== (kind === 'user')) {
857
+ out.push(violation('G3', { seq: event.seq, lineNo, eventType: event.type },
858
+ `session/title ${event.seq} messageSeqs must be empty exactly for a user title(官方原文;source.kind=${String(kind)},seqs=${seqs.length})`));
859
+ }
860
+ }
861
+ for (const s of seqs) {
862
+ const src = bySeq.get(s);
863
+ if (!src || src.type !== 'user/message' || src.data?.source?.kind !== 'user') {
864
+ out.push(violation('G3', { seq: event.seq, lineNo, eventType: event.type },
865
+ `${event.type} ${event.seq} messageSeqs must cite earlier human user/message events(seq ${String(s)} 不是更早的人类 user/message)`));
866
+ }
867
+ }
868
+ }
869
+ return out;
870
+ }
package/lib/compat.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * dsh-log-contract · lib/compat.js —— 兼容层(任务 1.3 · 装即坏修复;2026-09-14 语义对齐重写)
3
+ *
4
+ * 背景:本包原先从 `@deepseek-ai/dsh-session` 直接导入 `decodeStorageRecord` 与 `isJsonValue`。
5
+ * 官方 0.1.5 树已**不再从包根导出**这两个符号(0.1.5-rc.1 `lib/index.js` 的 26 个导出里没有它们),
6
+ * 而本包是 plugin 的依赖 ⇒ 升级后"装即坏"(模块加载期报 does not provide an export named ...)。
7
+ *
8
+ * 本文件把这两个符号**本地化**,并把语义从"近似"改为**逐条移植**(原实现是宽松近似:
9
+ * `decodeStorageRecord` 对损坏 chunk 行返回 `[value]` 而非抛错,导致 R2 永不触发;
10
+ * `isJsonValue` 不拒 `-0`、不拒稀疏数组、不查原型——与官方 lossless-JSON 边界不一致)。
11
+ *
12
+ * 移植源(一手,2026-09-14 实测):
13
+ * - `decodeStorageRecord` / `validateRow` / `expandRow`:
14
+ * `@deepseek-ai/dsh-session@0.1.0-rc.7` `lib/index.js:922-1035`(该版本是最后一个把
15
+ * `decodeStorageRecord` 从包根导出的版本;与 App 2.0.9 内置的
16
+ * `dsh-session/lib/types/chunk-rows.js`(= 0.1.2-rc.1,sha256 前 16 位 `5724c4f798ed07e7`)
17
+ * 校验规则一致)。**fail-loud**:损坏的 chunk 行必须抛错——它是损坏存储,静默当普通事件
18
+ * 处理会丢掉整段 run。
19
+ * - `isJsonValue`:`@deepseek-ai/dsh-session@0.1.0-rc.7` `lib/index.js:74-205`
20
+ * `walkJsonValue(value, false)`(lossless-JSON 边界:拒绝稀疏数组、非有限数、`-0`、
21
+ * 循环引用、非普通原型、symbol/不可枚举键)。
22
+ *
23
+ * 仍从官方导入的符号(升级后保留):`foldSurface` / `isSurfaceEligibleType` /
24
+ * `KNOWN_SESSION_EVENT_TYPES`(见 `./vocab.js` / `./checks.js`)。
25
+ */
26
+
27
+ /** Whether a value is a JSON-visible record (object, not null, not array)(rc.7 `isRecord`)。 */
28
+ function isRecord(value) {
29
+ return typeof value === 'object' && value !== null;
30
+ }
31
+
32
+ /** Exact-key check: `value` has every key in `keys` and nothing else(rc.7 `hasExactKeys`)。 */
33
+ function hasExactKeys(value, keys) {
34
+ return Object.keys(value).length === keys.length && keys.every((k) => Object.hasOwn(value, k));
35
+ }
36
+
37
+ //#region isJsonValue —— rc.7 walkJsonValue 的移植(不含 detach)
38
+ /** Whether a value is an object whose prototype is an intrinsic (plain) object prototype, across realms. */
39
+ function isIntrinsicObjectPrototype(prototype) {
40
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
41
+ }
42
+ /** Whether an object is a plain or null-prototype record from any JavaScript realm. */
43
+ function hasPlainObjectPrototype(value) {
44
+ const prototype = Object.getPrototypeOf(value);
45
+ return prototype === null || (typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype));
46
+ }
47
+ /** Whether an array is a plain array from any JavaScript realm (rejects subclasses/exotics). */
48
+ function hasPlainArrayPrototype(value) {
49
+ const prototype = Object.getPrototypeOf(value);
50
+ if (prototype === Array.prototype) return true;
51
+ return typeof prototype === 'object' && prototype !== null && Object.getPrototypeOf(prototype) === Object.prototype && Object.getPrototypeOf(Object.getPrototypeOf(prototype)) === null;
52
+ }
53
+ /** Return every JSON-visible object key, or reject own data JSON would discard. */
54
+ function enumerableStringKeys(value) {
55
+ const keys = Reflect.ownKeys(value);
56
+ if (keys.some((key) => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) return undefined;
57
+ return keys;
58
+ }
59
+
60
+ /**
61
+ * Validate lossless JSON iteratively(rc.7 `walkJsonValue(value, false)`)。
62
+ * @param {unknown} value
63
+ * @returns {boolean}
64
+ */
65
+ export function isJsonValue(value) {
66
+ const ancestors = new Set();
67
+ const tasks = [{ kind: 'visit', value }];
68
+ for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
69
+ if (task.kind === 'leave') {
70
+ ancestors.delete(task.source);
71
+ continue;
72
+ }
73
+ if (task.kind === 'array-item') {
74
+ if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return false;
75
+ tasks.push({ kind: 'visit', value: task.source[task.index] });
76
+ continue;
77
+ }
78
+ if (task.kind === 'object-property') {
79
+ tasks.push({ kind: 'visit', value: task.source[task.key] });
80
+ continue;
81
+ }
82
+ const current = task.value;
83
+ if (current === null) continue;
84
+ if (typeof current === 'boolean' || typeof current === 'string') continue;
85
+ if (typeof current === 'number') {
86
+ if (!Number.isFinite(current) || Object.is(current, -0)) return false;
87
+ continue;
88
+ }
89
+ if (typeof current !== 'object') return false;
90
+ if (ancestors.has(current)) return false;
91
+ if (Array.isArray(current)) {
92
+ if (!hasPlainArrayPrototype(current)) return false;
93
+ const length = current.length;
94
+ if (Reflect.ownKeys(current).length !== length + 1) return false;
95
+ ancestors.add(current);
96
+ tasks.push({ kind: 'leave', source: current });
97
+ for (let index = length - 1; index >= 0; index--) tasks.push({ kind: 'array-item', source: current, index });
98
+ continue;
99
+ }
100
+ if (!hasPlainObjectPrototype(current)) return false;
101
+ const keys = enumerableStringKeys(current);
102
+ if (keys === undefined) return false;
103
+ ancestors.add(current);
104
+ tasks.push({ kind: 'leave', source: current });
105
+ for (let index = keys.length - 1; index >= 0; index--) {
106
+ const key = keys[index];
107
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
108
+ if (key === undefined) return false;
109
+ tasks.push({ kind: 'object-property', source: current, key });
110
+ }
111
+ }
112
+ return true;
113
+ }
114
+ //#endregion
115
+
116
+ //#region decodeStorageRecord —— rc.7 validateRow/expandRow 的移植
117
+ /** Throw the uniform malformed-row diagnostic(rc.7 `malformed`)。 */
118
+ function malformed(tag, why) {
119
+ throw new Error(`malformed ${tag} storage row: ${why}`);
120
+ }
121
+
122
+ /** Validate the shared run-data fields and the payload/dt arity; returns the member payload(rc.7 `validateRunData`)。 */
123
+ function validateRunData(tag, data, payloadKey) {
124
+ if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') malformed(tag, 'turn/step/index must be numbers');
125
+ const payload = data[payloadKey];
126
+ if (!Array.isArray(payload) || payload.length === 0 || payload.some((entry) => typeof entry !== 'string')) malformed(tag, `${payloadKey} must be a non-empty string array`);
127
+ const dt = data.dt;
128
+ if (!Array.isArray(dt) || dt.some((gap) => !Number.isSafeInteger(gap))) malformed(tag, 'dt must be an array of safe integers');
129
+ if (dt.length !== payload.length - 1) malformed(tag, `dt length ${dt.length} does not match ${payload.length} members`);
130
+ return payload;
131
+ }
132
+
133
+ /** Validate a row-tagged parsed value's envelope and data, throwing on any malformation(rc.7 `validateRow`)。 */
134
+ function validateRow(value, tag) {
135
+ if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) malformed(tag, 'envelope must be exactly {type, seq0, time0, data}');
136
+ if (!Number.isSafeInteger(value.seq0) || value.seq0 < 0) malformed(tag, 'seq0 must be a non-negative safe integer');
137
+ if (!Number.isSafeInteger(value.time0)) malformed(tag, 'time0 must be a safe integer');
138
+ const data = value.data;
139
+ if (!isRecord(data)) malformed(tag, 'data must be an object');
140
+ let payload;
141
+ if (tag === 'tool-call-chunks') {
142
+ const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args']);
143
+ if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) malformed(tag, 'data must be exactly {turn, step, index, id, name?, dt, args}');
144
+ if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) malformed(tag, 'id (and name when present) must be strings');
145
+ payload = validateRunData(tag, data, 'args');
146
+ } else {
147
+ if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) malformed(tag, 'data must be exactly {turn, step, index, dt, texts}');
148
+ payload = validateRunData(tag, data, 'texts');
149
+ }
150
+ if (!Number.isSafeInteger(value.seq0 + payload.length - 1)) malformed(tag, 'member seqs must stay safe integers');
151
+ let time = value.time0;
152
+ for (const gap of data.dt) {
153
+ time += gap;
154
+ if (!Number.isSafeInteger(time)) malformed(tag, 'member times must stay safe integers');
155
+ }
156
+ return value;
157
+ }
158
+
159
+ /** Expand a validated row back into its exact original events, in order(rc.7 `expandRow`)。 */
160
+ function expandRow(row) {
161
+ const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts;
162
+ const events = [];
163
+ let time = row.time0;
164
+ for (let k = 0; k < members.length; k++) {
165
+ if (k > 0) time += row.data.dt[k - 1];
166
+ let chunk;
167
+ switch (row.type) {
168
+ case 'text-chunks':
169
+ chunk = { type: 'text-delta', index: row.data.index, text: members[k] };
170
+ break;
171
+ case 'reasoning-chunks':
172
+ chunk = { type: 'reasoning-delta', index: row.data.index, text: members[k] };
173
+ break;
174
+ case 'tool-call-chunks':
175
+ chunk = {
176
+ type: 'tool-call-delta',
177
+ index: row.data.index,
178
+ id: row.data.id,
179
+ ...Object.hasOwn(row.data, 'name') ? { name: row.data.name } : {},
180
+ argumentsDelta: members[k],
181
+ };
182
+ break;
183
+ /* v8 ignore next 4 -- validateRow only returns the three row tags */
184
+ default:
185
+ throw new Error(`chunk-rows received unsupported row ${String(row)}`);
186
+ }
187
+ events.push({ type: 'assistant/chunk', seq: row.seq0 + k, time, data: { turn: row.data.turn, step: row.data.step, chunk } });
188
+ }
189
+ return events;
190
+ }
191
+
192
+ /**
193
+ * 存储行解码:chunk 行校验后展开为完整事件序列,其他行原样返回(rc.7 `decodeStorageRecord`)。
194
+ * 损坏的 chunk 行**抛错**(fail-loud)——调用方(log-reader)据此落 `row.error` → R2。
195
+ * @param {unknown} value 已 JSON.parse 的一行
196
+ * @returns {unknown[]} 事件数组
197
+ */
198
+ export function decodeStorageRecord(value) {
199
+ if (!isRecord(value)) return [value];
200
+ const tag = value.type;
201
+ if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') return [value];
202
+ return expandRow(validateRow(value, tag));
203
+ }
204
+ //#endregion
205
+
206
+ //#region decodeSeqRanges —— v3 storage-form 区间编码解码(0.1.5 `lib/types/seq-ranges.js` 逐条移植)
207
+ // 为什么本地化:`decodeSeqRanges` 只在 0.1.5+ 从包根导出;`0.1.0-rc.7` 实测 `undefined`
208
+ // (见 vocab.js 的双宿主声明)。本包 peer 允许两个版本 ⇒ 必须在两种宿主上都能解码。
209
+ //
210
+ // 语义(官方 `seq-ranges.js:34-73`):`sourceEventSeqs` 的 JSON 存储形态是"数字 + 闭区间对"
211
+ // 混合数组,例 `[3, [174,176], 180, …]` ⇒ 内存形态 `[3,174,175,176,180,…]`。
212
+ // v3 持久化层对连续段用区间对压缩(App 2.0.9 真实 v3 会话实测:1 个 `user/message` replace
213
+ // 的 `sourceEventSeqs` 2251 个存储项展开为 2259 个 seq)。**不解码 = 拿物理未展开的序列
214
+ // 喂 S5/S6 与官方 foldSurface ⇒ 真实健康会话被误判 S5/S6/S8 + `--resume` broken。**
215
+
216
+ /** 非负安全整数(rc.7/0.1.5 `assertSeq`)。 */
217
+ function assertSeq(value) {
218
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError('sourceEventSeqs must contain non-negative safe integers');
219
+ }
220
+ /** 带 Session 序号 brand 的断言(官方 `SessionSeq`;额外拒绝 `-0`)。 */
221
+ function sessionSeq(value) {
222
+ if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) {
223
+ throw new TypeError(`SessionSeq must be a non-negative safe integer, got ${String(value)}`);
224
+ }
225
+ return value;
226
+ }
227
+
228
+ /**
229
+ * 展开 JSON 存储形态的 `sourceEventSeqs`(官方 `decodeSeqRanges` 逐条移植)。
230
+ * 任一形态非法即抛 `TypeError`(fail-loud:调用方据此报 S6/E4,不静默当稠密序列)。
231
+ * @param {unknown} value 已 JSON.parse 的 `sourceEventSeqs`
232
+ * @param {number} [maxEntries] 该事件允许的最大成员数
233
+ * @returns {number[]} 内存稠密序列
234
+ */
235
+ export function decodeSeqRanges(value, maxEntries = Number.MAX_SAFE_INTEGER) {
236
+ if (!Array.isArray(value)) throw new TypeError('sourceEventSeqs must be an array');
237
+ const decoded = [];
238
+ let hasRange = false;
239
+ for (const entry of value) {
240
+ if (typeof entry === 'number') {
241
+ assertSeq(entry);
242
+ if (decoded.length >= maxEntries) throw new TypeError('sourceEventSeqs exceeds its event sequence');
243
+ decoded.push(sessionSeq(entry));
244
+ continue;
245
+ }
246
+ if (!Array.isArray(entry) || entry.length !== 2) {
247
+ throw new TypeError('sourceEventSeqs range entries must be [start, end] pairs');
248
+ }
249
+ const start = entry[0];
250
+ const end = entry[1];
251
+ assertSeq(start);
252
+ assertSeq(end);
253
+ if (end < start) throw new TypeError('sourceEventSeqs ranges require start <= end');
254
+ if (end - start + 1 > maxEntries - decoded.length) {
255
+ throw new TypeError('sourceEventSeqs range exceeds its event sequence');
256
+ }
257
+ for (let seq = start; seq <= end; seq += 1) decoded.push(sessionSeq(seq));
258
+ hasRange = true;
259
+ }
260
+ if (hasRange && !decoded.every((v, i) => i === 0 || v > decoded[i - 1])) {
261
+ throw new TypeError('sourceEventSeqs ranges must be strictly increasing');
262
+ }
263
+ return decoded;
264
+ }
265
+
266
+ /**
267
+ * 把一个已解码事件的 storage-form `sourceEventSeqs` 归一成内存稠密序列。
268
+ * - 无 `sourceEventSeqs` / 非对象 → 原样返回(同一引用);
269
+ * - 解码成功 → 返回**新对象**(不修改入参;调用方须使用返回值);
270
+ * - 解码失败 → 原样返回,保留存储形态交由 S6/E4 报违规(读取层不崩、不吞错)。
271
+ */
272
+ export function normalizeEventSeqRanges(event) {
273
+ if (!isRecord(event) || event.sourceEventSeqs === undefined) return event;
274
+ let expanded;
275
+ try {
276
+ expanded = decodeSeqRanges(event.sourceEventSeqs);
277
+ } catch {
278
+ return event;
279
+ }
280
+ return { ...event, sourceEventSeqs: expanded };
281
+ }
282
+ //#endregion
package/lib/contracts.js CHANGED
@@ -21,6 +21,7 @@ export const LAYER = {
21
21
  PLUGIN: 'plugin', // 插件语义层:marker 隐藏语义
22
22
  CONCURRENCY: 'concurrency', // 并发/写入者假设
23
23
  FRAMING: 'framing', // zstd 帧结构
24
+ MIGRATION: 'migration', // 迁移预检层:官方 v0/v1/v2 → 当前格式的升级路径会不会拒(**独立维度**)
24
25
  };
25
26
 
26
27
  export const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' };
@@ -35,6 +36,7 @@ export const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' };
35
36
  * - P 插件 marker 语义层
36
37
  * - C 并发 / 写入者假设
37
38
  * - Z zstd 帧结构
39
+ * - G 迁移预检(migration gate,**独立维度**:官方迁移会不会拒;不进 ok/verdict)
38
40
  */
39
41
  export const CONTRACT_RULES = [
40
42
  // ── H · header ──────────────────────────────────────────────────────────
@@ -51,8 +53,8 @@ export const CONTRACT_RULES = [
51
53
  title: 'header 版本与必填字段',
52
54
  layer: LAYER.PERSISTENCE,
53
55
  severity: SEVERITY.ERROR,
54
- source: '@deepseek-ai/dsh-session lib/index.js:1110-1125',
55
- description: 'header.version 必须为 0;id 为字符串;createdAt 为非负安全整数;cwd 若存在必须为绝对路径;origin 只能为 "subagent"。',
56
+ source: '@deepseek-ai/dsh-session lib/index.js:1110-1125;已知格式版本 0/1/2/3(App 2.0.9 内置 v0→v1→v2→v3 迁移,SESSION_FORMAT_VERSION=3)',
57
+ description: 'header.version 必须为已知受支持版本(0/1/2/3);未知版本报 H2。id 为字符串;createdAt 为非负安全整数;cwd 若存在必须为绝对路径;origin 只能为 "subagent"。',
56
58
  },
57
59
 
58
60
  // ── R · 存储行 ──────────────────────────────────────────────────────────
@@ -209,8 +211,8 @@ export const CONTRACT_RULES = [
209
211
  title: '整日志 foldSurface 可重放',
210
212
  layer: LAYER.PERSISTENCE,
211
213
  severity: SEVERITY.ERROR,
212
- source: '@deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface);复盘"官方 foldSurface 不抛 = 通过"',
213
- description: '终验:把全部事件按序喂给官方 foldSurface,不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。',
214
+ source: '@deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface, v3);v0/v1/v2 用本地等价实现 lib/legacy-fold.js(rc.7 lib/index.js:229-455 逐条移植)',
215
+ description: '终验:按被检文件 header.version 选折叠器(v3 → 官方 foldSurface;v0/v1/v2 → 本地 legacyFoldSurface),不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。注意 0.1.5 的官方 foldSurface 是 v3 语义(replace 用 startSeq/endSeq、assistant/message 禁 sourceEventSeqs),对旧格式文件会误报,不可借用。',
214
216
  },
215
217
 
216
218
  {
@@ -358,6 +360,36 @@ export const CONTRACT_RULES = [
358
360
  source: 'OpenAI 兼容端点对 tool 消息顺序的严格校验;DSH 序列化器将混合 user 消息展开为 text 在前、tool-result 在后',
359
361
  description: '当仍有未满足的 assistant tool-call 时出现 user 文本消息,会产生 [assistant(tool_calls), user(text), tool] 序列,严格端点同样拒绝。',
360
362
  },
363
+
364
+ // ── G · 迁移预检(migration precheck;**独立维度**,不是"可加载"判定)──────────
365
+ // 存在理由:官方把 v0/v1/v2 会话升到当前格式时会做一轮**迁移校验**,其规则比本工具的
366
+ // "读取/折叠"规则集更严(官方原文见各条 source)。本工具判"可加载"≠"可升级"。
367
+ // 这些规则只描述"官方迁移会不会拒",因此 severity 固定 warning、layer 固定 migration:
368
+ // 它们**不参与** ok / loadable / resumable / compactable,只喂 `migrationVerdict()`。
369
+ {
370
+ id: 'G1',
371
+ title: '迁移预检:v0 源文件的 subagent/descriptor.data.version 必须为 3',
372
+ layer: LAYER.MIGRATION,
373
+ severity: SEVERITY.WARNING,
374
+ source: '@deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2 lib/index.js:1584-1586(assertReleasedEventPayload):data.version !== 3 且源版本 === 0 → SessionFormatUnsupportedMigrationError("uses unsupported descriptor version N");源版本 1/2 时官方提前 return(容忍)',
375
+ description: '文件版本 0 且事件类型为 subagent/descriptor 且 data.version !== 3 → 官方 v0→v1 迁移直接拒绝(消息形如 `subagent/descriptor <seq> uses unsupported descriptor version 2`);源版本 1/2 不受此条约束。',
376
+ },
377
+ {
378
+ id: 'G2',
379
+ title: '迁移预检:v0 源文件不得含词表外的历史事件类型(含 ignorable)',
380
+ layer: LAYER.MIGRATION,
381
+ severity: SEVERITY.WARNING,
382
+ source: '@deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2 lib/index.js:1580-1583(assertReleasedEventPayload):RELEASED_V0_EVENT_DISPOSITIONS 里没有该 type → "format v0 contains unknown historical event type … migration refuses unknown historical events even when ignorable"',
383
+ description: '文件版本 0 且事件 type 不在官方 v0 dispositions(本包 vendored 为 V0_EVENT_TYPES)内 → 官方迁移拒绝,**即使该事件带 ignorable:true**。E3 的 ignorable 豁免是**读取路径**语义(不改),本条只在迁移预检维度表达。',
384
+ },
385
+ {
386
+ id: 'G3',
387
+ title: '迁移预检:v0 源 session/title 系列的 messageSeqs 必须引用更早的人类 user/message',
388
+ layer: LAYER.MIGRATION,
389
+ severity: SEVERITY.WARNING,
390
+ source: '@deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2 lib/index.js:2543-2556(assertTitleSources):`session/title` 的 messageSeqs 为空 ⟺ source.kind === "user";每个被引 seq 必须解析到 `user/message` 且其 `data.source.kind === "user"`,否则 "messageSeqs must cite earlier human user/message events" / "must be empty exactly for a user title"',
391
+ description: '文件版本 0 且事件为 session/title 或 session/title-llm-request:messageSeqs 必须是数组;session/title 的"空数组 ⟺ 用户标题"必须成立;每个被引 seq 必须是更早的 user/message 且 source.kind === "user"。违反 → 官方升级到当前格式时拒绝。**应用面说明**:官方 `assertTitleSources` 与 turn/start 状态机同在 `assertReleasedArtifactRelationships`,由 v1→v2 在**变换后的 v1/v2 artifact** 上调用(`dsh-session-format-v1-to-v2/lib/index.js:104`);对 messageSeqs 这类按 seq 索引 + 类型/source 判定的引用,变换保序保类型 ⇒ 在原始 v0 上判是必要条件的近似,实测 281 真实 v0 上 0 误报。',
392
+ },
361
393
  ];
362
394
 
363
395
  /** 按 id 取规则。 */
package/lib/index.js CHANGED
@@ -5,9 +5,10 @@
5
5
  * 公开 API:离线体检 + 写前校验 + 修复 + 契约目录。
6
6
  */
7
7
  export { loadSessionLog, tailSeq, readSessionHeader } from './log-reader.js';
8
- export { validateSessionLog, resumeVerdict } from './validate.js';
8
+ export { validateSessionLog, resumeVerdict, migrationVerdict, assessmentScope } 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 { HOST_MAX_FILE_VERSION, hostPackageVersion, hostCapability, isAssessableFileVersion } from './vocab.js';
12
13
  export { tokenMeterViolations, tokenMeterSourceViolations, stepKeyViolations, nullTurnStepViolations, turnEndReasonViolations, ignorableTypeViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
13
14
  export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';