dsh-log-contract 0.3.10 → 0.3.12

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/prewrite.js CHANGED
@@ -17,7 +17,9 @@
17
17
  * 所有判定复用 `lib/checks.js`(与离线体检同一套逻辑),
18
18
  * 保证"体检看到的问题 = 写入前拦下的问题"。
19
19
  */
20
- import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, turnEndReasonViolations, violation } from './checks.js';
20
+ import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, turnEndReasonViolations, violation, wireViolations } from './checks.js';
21
+ import { resolveFormatVersion } from './vocab.js';
22
+ import { normalizeEventSeqRanges } from './compat.js';
21
23
 
22
24
  /** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
23
25
  function isKnownMarkerCandidate(event) {
@@ -32,17 +34,28 @@ function normalizeCandidate(candidate, nextSeq) {
32
34
  /**
33
35
  * 基于当前日志事件列表建立写前校验器。
34
36
  *
35
- * @param {{ events: Array<object>, baseSeq?: number }} input 当前日志的已解码事件
36
- * (按日志顺序;无 seq 字段的事件按位置补 seq,用于窗口校验)。
37
+ * **格式版本(C2)**:`formatVersion` > `header.version` > 事件形状推断 > 0,在**本次
38
+ * `createPreWriter` 调用内固定**并显式传给每条按版本择路的判定。**不再读写模块级全局**
39
+ * `fileVersion`——旧实现下同一进程"先 validate(v3) 再 prewrite(v0)"会把同一份合法 v0 输入
40
+ * 的结论翻成 S4+S8(独立审核复现 B)。下游 `dsh-retrace` 正是只传 `events` 直接调用
41
+ * (`lib/prewrite-guard.js:166`),所以缺省时必须能自行推断,不能把 v3 输入按 0 处理
42
+ * (否则首次调用即 10 条 E3/S2/S8 误报,复现 C)。
43
+ *
44
+ * @param {{ events: Array<object>, baseSeq?: number, formatVersion?: number, header?: object|null }} input
45
+ * 当前日志的已解码事件(按日志顺序;无 seq 字段的事件按位置补 seq,用于窗口校验)。
46
+ * `formatVersion`/`header` 二者其一优先决定被检文件的格式版本。
37
47
  * @returns {{
38
- * events: Array, nextSeq: number,
48
+ * events: Array, nextSeq: number, formatVersion: number,
39
49
  * validateAppend(candidate, opts?): { ok, violations, stateAfter },
40
50
  * validateEdit(editedEvents, opts?): { ok, violations, stateAfter },
41
51
  * }}
42
52
  */
43
53
  export function createPreWriter(input = {}) {
44
- const { baseSeq = 0 } = input;
45
- let events = [...input.events];
54
+ const { baseSeq = 0, header = null } = input;
55
+ // C1:v3 storage-form 区间编码 → 内存稠密序列(与 log-reader 同一归一,覆盖"直接传 events"入口)
56
+ let events = (input.events ?? []).map(normalizeEventSeqRanges);
57
+ // C2:版本显式解析并固定在本次调用内(不读模块级全局)
58
+ const formatVersion = resolveFormatVersion({ formatVersion: input.formatVersion, header, events });
46
59
  // 窗口校验支持"无 seq 的原始事件列表":按位置补齐 seq 与 time。
47
60
  let nextSeq = baseSeq;
48
61
  events = events.map((e) => {
@@ -71,18 +84,21 @@ export function createPreWriter(input = {}) {
71
84
  // E1/E3/E4/E5/E6 + M1 + P1/P2 —— 逐事件
72
85
  for (const event of candidateEvents) {
73
86
  const loc = { seq: event.seq, lineNo: null, eventType: event.type };
74
- violations.push(...envelopeViolations(event, loc));
87
+ violations.push(...envelopeViolations(event, loc, formatVersion));
75
88
  violations.push(...engineViolations(event, loc));
76
89
  violations.push(...pluginViolations(event, loc));
77
90
  }
78
91
  // S1–S7 —— 与官方同语义的增量重放(含拟写事件)
79
- const replay = replaySurface(candidateEvents.map((event) => ({ event })));
92
+ const replay = replaySurface(candidateEvents.map((event) => ({ event })), formatVersion);
80
93
  violations.push(...replay.violations);
81
- // S8 —— 官方 foldSurface 终验
82
- const folded = finalFold(candidateEvents);
94
+ // S8 —— fold 终验(按当前文件版本选:v3 官方 foldSurface / v0–v2 本地 legacyFoldSurface)
95
+ const folded = finalFold(candidateEvents, formatVersion);
83
96
  if (folded.error) {
84
- violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
97
+ violations.push(violation('S8', { lineNo: null }, `foldSurface 重放失败(按文件版本选:v3 官方 / v0–v2 本地等价):${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
85
98
  }
99
+ // W1/W2 —— wire 流配对(2026-09-09 V3 验证补:离线 check 有、写前原漏——悬空 tool
100
+ // 编辑必须写前拦,否则违约写入先落盘、体检才报 = 晚一步)
101
+ violations.push(...wireViolations(candidateEvents.map((event) => ({ event })), formatVersion));
86
102
  // T1 —— token-meter 配对(事故根因 3 固化)。写前校验只判定**拟写事件自身**
87
103
  // 的 step 配对:retrace 的 turn-null 编辑/撤回 marker 必然命中(空
88
104
  // assistant/message replace 无 step 可配对),但编辑功能必须可用——白名单
@@ -129,6 +145,7 @@ export function createPreWriter(input = {}) {
129
145
 
130
146
  return {
131
147
  events,
148
+ formatVersion,
132
149
  get nextSeq() {
133
150
  return nextSeq;
134
151
  },
@@ -147,7 +164,7 @@ export function createPreWriter(input = {}) {
147
164
  nextSeq,
148
165
  };
149
166
  }
150
- const normalized = normalizeCandidate(candidate, nextSeq);
167
+ const normalized = normalizeEventSeqRanges(normalizeCandidate(candidate, nextSeq));
151
168
  const after = [...events, normalized];
152
169
  const result = runChecks(after, nextSeq); result.stateAfter = {
153
170
  events: after,
@@ -172,9 +189,10 @@ export function createPreWriter(input = {}) {
172
189
  nextSeq,
173
190
  };
174
191
  }
175
- const result = runChecks(editedEvents, undefined);
192
+ const edited = editedEvents.map(normalizeEventSeqRanges);
193
+ const result = runChecks(edited, undefined);
176
194
  result.stateAfter = {
177
- events: editedEvents,
195
+ events: edited,
178
196
  nextSeq: result.nextSeq,
179
197
  surfaceNodes: result.surface?.nodes ?? [],
180
198
  };
@@ -189,5 +207,5 @@ export function createPreWriter(input = {}) {
189
207
  */
190
208
  export function preWriterFromLog(log) {
191
209
  const events = (log.events ?? []).map((e) => e.event);
192
- return createPreWriter({ events });
210
+ return createPreWriter({ events, header: log?.header ?? null });
193
211
  }
package/lib/validate.js CHANGED
@@ -3,18 +3,24 @@
3
3
  *
4
4
  * 离线体检引擎:对 `loadSessionLog` 的结果逐条跑契约规则,产出违规报告。
5
5
  *
6
- * 判定哲学(复盘事故 §四-1):**持久化层以官方 `foldSurface` 不抛为通过**,
6
+ * 判定哲学(复盘事故 §四-1):**持久化层以 fold 不抛为通过**,
7
7
  * 但为定位问题,先用与官方同语义的增量重放做逐事件归因(S1–S7),
8
- * 再跑官方 foldSurface 作终验(S8)——两套都绿才算过。
8
+ * 再跑 fold 终验(S8)——两套都绿才算过。
9
+ * 终验按**被检文件 header.version** 选:v3 → 运行时官方 `foldSurface`;
10
+ * v0/v1/v2 → 本地等价实现 `legacyFoldSurface`(0.1.5 的 foldSurface 是 v3 语义,对旧格式会误报)。
9
11
  */
10
12
  import { ruleById } from './contracts.js';
13
+ import { normalizeEventSeqRanges } from './compat.js';
14
+ import { HOST_MAX_FILE_VERSION, MIGRATION_TARGET_VERSION, hostCapability, isAssessableFileVersion } from './vocab.js';
11
15
  import {
12
16
  CHUNK_ROW_TYPES,
13
17
  envelopeViolations,
18
+ ignorableTypeViolations,
14
19
  engineViolations,
15
20
  finalFold,
16
21
  inboxReplayViolations,
17
22
  isSafeInt,
23
+ migrationPrecheckViolations,
18
24
  nullTurnStepViolations,
19
25
  physicalOrderViolations,
20
26
  pluginViolations,
@@ -29,6 +35,9 @@ import {
29
35
  wireViolations,
30
36
  } from './checks.js';
31
37
 
38
+ /** 宿主能力闸触发时**跳过**的规则(v3/被检版本语义相关)——报告里显式列出,避免"静默通过"。 */
39
+ const VERSION_DEPENDENT_RULES = ['E3', 'E7', 'S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8', 'W1', 'W2', 'T1', 'T2', 'T3', 'T4', 'T5', 'G1', 'G2'];
40
+
32
41
  /**
33
42
  * 对会话日志执行全量离线体检。
34
43
  *
@@ -44,7 +53,23 @@ import {
44
53
  export function validateSessionLog(log, opts = {}) {
45
54
  const { baseSeq = 0 } = opts;
46
55
  const violations = [];
47
- const { header, headerLine, rows, events, frameInfo } = log;
56
+ const { header, headerLine, rows, frameInfo } = log;
57
+ // C1 防御(多入口一致):即使调用方手搓 log 对象、未经 `loadSessionLog`,也在体检入口
58
+ // 归一一次 v3 区间编码。正常路径(loadSessionLog 已展开)下这是幂等的恒等映射。
59
+ const events = (log.events ?? []).map((e) => ({ ...e, event: normalizeEventSeqRanges(e.event) }));
60
+ // 1.3 第二半:词表/折叠路径按**被检文件自身版本**选择(不按运行时)。
61
+ // C2:版本是**本次调用的局部量**,显式传给每条按版本择路的判定——不再写模块级全局
62
+ // (旧实现 `setFileFormatVersion()` 会被同进程后调用的 prewrite 读到,造成结论翻转)。
63
+ const formatVersion = Number.isSafeInteger(header?.version) && header.version >= 0 ? header.version : 0;
64
+
65
+ // ── 宿主能力闸(S2·安全)──────────────────────────────────────────────────
66
+ // 被检文件版本 > 本宿主支持的最大文件版本 ⇒ 本宿主没有该版本的词表/折叠语义。继续按
67
+ // 本宿主语义判定会产出**假阳性**(独立复核实测:rc.7 上同一真实 v3 文件 →
68
+ // `S8×1 + E3×40 / verdict=broken / loadable:false`),危险是用户以为日志坏了去跑
69
+ // `fix --apply`。改为:只跑与版本无关的结构检查 + 显式"不可在本宿主评估"档。
70
+ if (!isAssessableFileVersion(formatVersion)) {
71
+ return unassessableResult({ header, headerLine, rows, events, frameInfo, baseSeq, formatVersion });
72
+ }
48
73
 
49
74
  // ── Z · 帧结构 ─────────────────────────────────────────────────────────
50
75
  if (frameInfo?.torn) {
@@ -61,8 +86,8 @@ export function validateSessionLog(log, opts = {}) {
61
86
  if (header.type !== 'session') {
62
87
  violations.push(violation('H1', { lineNo: headerLine }, `首行 type 必须为 "session"(实际 ${String(header.type)})`));
63
88
  }
64
- if (header.version !== 0) {
65
- violations.push(violation('H2', { lineNo: headerLine }, `header.version 必须为 0(实际 ${String(header.version)})——格式版本演进无迁移机制(F1)`));
89
+ if (![0, 1, 2, 3].includes(header.version)) {
90
+ violations.push(violation('H2', { lineNo: headerLine }, `header.version 未知(实际 ${String(header.version)})——已知 0/1/2/3;未知版本按 0 处理会误判,请先确认格式`));
66
91
  }
67
92
  if (typeof header.id !== 'string' || header.id === '') {
68
93
  violations.push(violation('H2', { lineNo: headerLine }, 'header.id 必须为非空字符串'));
@@ -78,6 +103,13 @@ export function validateSessionLog(log, opts = {}) {
78
103
  }
79
104
  }
80
105
 
106
+ // ── Z3 · 空文件体检(2026-09-09 反向挑刺 T3 增量)──────────────────────────
107
+ // 空态无覆盖:36 条规则全来自"有内容"事故,空会话文件应显式报(不管 DSH 编排层怎么处理)。
108
+ // 有 header 但零事件 = 异常空会话(warning,不破坏 ok);连 header 都没有 → H1 已报。
109
+ if (header !== null && events.length === 0) {
110
+ violations.push(violation('Z3', { lineNo: headerLine }, `会话文件无任何事件(仅 header)——异常空会话(新建即空或写入未落盘);空态不在任何有内容规则的覆盖下,显式报出供人判断`));
111
+ }
112
+
81
113
  // ── R · 存储行 ─────────────────────────────────────────────────────────
82
114
  for (const row of rows) {
83
115
  if (row.error && row.value === null) {
@@ -92,7 +124,7 @@ export function validateSessionLog(log, opts = {}) {
92
124
  let seqBroken = false;
93
125
  for (const { event, lineNo } of events) {
94
126
  const loc = { seq: event.seq, lineNo, eventType: event.type };
95
- violations.push(...envelopeViolations(event, loc));
127
+ violations.push(...envelopeViolations(event, loc, formatVersion));
96
128
  if (typeof event.seq === 'number' && Number.isSafeInteger(event.seq) && event.seq >= 0) {
97
129
  if (event.seq !== expectedSeq) {
98
130
  const kind = event.seq < expectedSeq ? '倒退(backward)' : '缺口(gap)';
@@ -109,10 +141,10 @@ export function validateSessionLog(log, opts = {}) {
109
141
  violations.push(...physicalOrderViolations(rows));
110
142
 
111
143
  // ── S · surface 增量重放(归因)+ 官方 foldSurface 终验 ────────────────
112
- const replay = replaySurface(events);
144
+ const replay = replaySurface(events, formatVersion);
113
145
  violations.push(...replay.violations);
114
146
 
115
- const folded = finalFold(events.map((e) => e.event));
147
+ const folded = finalFold(events.map((e) => e.event), formatVersion);
116
148
 
117
149
  // ── T · token meter 配对(事故根因 3 + 2026-08-30 两类刷屏)──
118
150
  violations.push(...tokenMeterViolations(events));
@@ -124,6 +156,9 @@ export function validateSessionLog(log, opts = {}) {
124
156
  violations.push(...nullTurnStepViolations(events));
125
157
  violations.push(...turnEndReasonViolations(events));
126
158
 
159
+ // ── E7 · ignorable 未知 type 合法性(反向挑刺 T2)────────────────────
160
+ violations.push(...ignorableTypeViolations(events, formatVersion));
161
+
127
162
  // ── I1 · inbox seed 相对重放(fork 边界孤儿;交接书 L1)──────────────
128
163
  violations.push(...inboxReplayViolations(events, header));
129
164
 
@@ -131,11 +166,11 @@ export function validateSessionLog(log, opts = {}) {
131
166
  violations.push(...toolPairingViolations(events));
132
167
  violations.push(...toolResultStructureViolations(events));
133
168
  if (folded.error) {
134
- violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
169
+ violations.push(violation('S8', { lineNo: null }, `foldSurface 重放失败(按文件版本选:v3 官方 / v0–v2 本地等价):${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
135
170
  }
136
171
 
137
172
  // ── W · wire 消息流(严格端点拒绝的悬空 tool / 顺序破坏)──────────────
138
- violations.push(...wireViolations(events));
173
+ violations.push(...wireViolations(events, formatVersion));
139
174
 
140
175
  // ── M / P ──────────────────────────────────────────────────────────────
141
176
  for (const { event, lineNo } of events) {
@@ -149,6 +184,11 @@ export function validateSessionLog(log, opts = {}) {
149
184
  violations.push(violation('C1', { lineNo: null }, 'seq 缺口/倒退是多写入者(≥2 个 Host 进程共享同一 session 目录)并发写的典型后果;离线体检无法观测竞态本身,但此痕迹需人工核查(N6)'));
150
185
  }
151
186
 
187
+ // ── G · 迁移预检(**独立维度**:官方升级路径会不会拒;不进 ok/verdict)──────
188
+ // 官方把 v0/v1/v2 升到当前格式时的规则集比本工具的"读取/折叠"规则集更严:本工具判
189
+ // ok/compactable **不代表**官方迁移会接受。这里只加 migration 层 warning(见 contracts G1/G2)。
190
+ violations.push(...migrationPrecheckViolations(events, formatVersion));
191
+
152
192
  // ── 汇总 ───────────────────────────────────────────────────────────────
153
193
  violations.sort((a, b) => (a.seq ?? -1) - (b.seq ?? -1) || (a.lineNo ?? -1) - (b.lineNo ?? -1));
154
194
  const bySeverity = { error: 0, warning: 0, info: 0 };
@@ -167,14 +207,193 @@ export function validateSessionLog(log, opts = {}) {
167
207
  frames: frameInfo?.frames ?? 0,
168
208
  compressedBytes: frameInfo?.compressedBytes ?? 0,
169
209
  plaintextBytes: frameInfo?.plaintextBytes ?? 0,
210
+ fileVersion: formatVersion,
211
+ hostMaxFileVersion: HOST_MAX_FILE_VERSION,
212
+ assessable: true,
170
213
  };
171
214
 
172
- return {
215
+ const result = {
173
216
  ok: bySeverity.error === 0,
217
+ assessable: true,
174
218
  violations,
175
219
  summary,
176
220
  surface: folded.surface ?? { nodes: replay.nodes, replacements: [] },
177
221
  };
222
+ result.migration = migrationVerdict(result);
223
+ result.assessmentScope = result.migration.assessmentScope;
224
+ return result;
225
+ }
226
+
227
+ /**
228
+ * 宿主能力闸的结果(S2):被检文件版本 > 本宿主支持的最大版本。
229
+ *
230
+ * 只跑**与版本无关**的结构检查(Z 帧 / H header / R 行 / E1·E2·E4·E5·E6 信封不含 E3 词表 /
231
+ * S9 物理序 / M·P / P3·P4 / I1),其余(S1–S8/W/T/E3/E7/G)显式跳过并在 `notAssessable.skippedRules`
232
+ * 里列出。**ok 固定 false**(不认证),但 `structuralOk` 单独给出结构层结论——
233
+ * 语义是"本宿主评估不了",不是"日志坏了"。
234
+ */
235
+ function unassessableResult({ header, headerLine, rows, events, frameInfo, baseSeq, formatVersion }) {
236
+ const violations = [];
237
+ const cap = hostCapability();
238
+ const notAssessable = {
239
+ fileVersion: formatVersion,
240
+ hostMaxFileVersion: HOST_MAX_FILE_VERSION,
241
+ hostPackage: cap.hostPackage,
242
+ skippedRules: VERSION_DEPENDENT_RULES,
243
+ reason: `被检文件 version=${formatVersion} 高于本宿主 @deepseek-ai/dsh-session@${cap.hostPackage} 支持的最大文件版本 ${HOST_MAX_FILE_VERSION}`
244
+ + `(本宿主只有 ≤${HOST_MAX_FILE_VERSION} 的词表/折叠语义)⇒ 按本宿主语义判定会产出假阳性(如 S8/E3)。`
245
+ + '这不表示日志损坏;请用 0.1.5+ 宿主评估。',
246
+ };
247
+
248
+ // ── Z · 帧结构(版本无关)──
249
+ if (frameInfo?.torn) violations.push(violation('Z1', { lineNo: null }, `zstd 尾帧撕裂:可能是写入中的 in-flight 帧或文件被截断(帧数 ${frameInfo.frames})`));
250
+ if (frameInfo?.error) violations.push(violation('Z2', { lineNo: null }, frameInfo.error));
251
+
252
+ // ── H · header(版本无关)──
253
+ if (header === null) {
254
+ violations.push(violation('H1', { lineNo: headerLine }, '首行不是合法 JSON —— 整个会话不可读'));
255
+ } else {
256
+ if (header.type !== 'session') violations.push(violation('H1', { lineNo: headerLine }, `首行 type 必须为 "session"(实际 ${String(header.type)})`));
257
+ if (![0, 1, 2, 3].includes(header.version)) violations.push(violation('H2', { lineNo: headerLine }, `header.version 未知(实际 ${String(header.version)})`));
258
+ if (typeof header.id !== 'string' || header.id === '') violations.push(violation('H2', { lineNo: headerLine }, 'header.id 必须为非空字符串'));
259
+ if (!Number.isSafeInteger(header.createdAt) || header.createdAt < 0) violations.push(violation('H2', { lineNo: headerLine }, 'header.createdAt 必须为非负安全整数'));
260
+ if (header.cwd !== undefined && (typeof header.cwd !== 'string' || !header.cwd.startsWith('/'))) violations.push(violation('H2', { lineNo: headerLine }, 'header.cwd 若存在必须为绝对路径'));
261
+ if (header.origin !== undefined && header.origin !== 'subagent') violations.push(violation('H2', { lineNo: headerLine }, `header.origin 只能为 "subagent"(实际 ${String(header.origin)})`));
262
+ }
263
+ if (header !== null && events.length === 0) violations.push(violation('Z3', { lineNo: headerLine }, '会话文件无任何事件(仅 header)——异常空会话'));
264
+
265
+ // ── R · 存储行(版本无关)──
266
+ for (const row of rows) {
267
+ if (row.error && row.value === null) violations.push(violation('R1', { lineNo: row.lineNo }, '该行不是合法 JSON(损坏行)'));
268
+ else if (row.error && CHUNK_ROW_TYPES.has(row.value?.type)) violations.push(violation('R2', { lineNo: row.lineNo }, `chunk 行 "${row.value.type}" 损坏:${row.error.message}`));
269
+ }
270
+
271
+ // ── E · 信封 + seq(E3 词表判定**跳过**:词表随被检版本而变,本宿主没有该版本词表)──
272
+ let expectedSeq = baseSeq;
273
+ let seqBroken = false;
274
+ for (const { event, lineNo } of events) {
275
+ const loc = { seq: event.seq, lineNo, eventType: event.type };
276
+ violations.push(...envelopeViolations(event, loc, formatVersion).filter((v) => v.id !== 'E3'));
277
+ if (typeof event.seq === 'number' && Number.isSafeInteger(event.seq) && event.seq >= 0) {
278
+ if (event.seq !== expectedSeq) {
279
+ violations.push(violation('E2', loc, `seq ${event.seq} 不连续:${event.seq < expectedSeq ? '倒退(backward)' : '缺口(gap)'},期望 ${expectedSeq}`));
280
+ seqBroken = true;
281
+ expectedSeq = event.seq + 1;
282
+ } else expectedSeq = event.seq + 1;
283
+ }
284
+ }
285
+ violations.push(...physicalOrderViolations(rows));
286
+ violations.push(...inboxReplayViolations(events, header));
287
+ violations.push(...toolPairingViolations(events));
288
+ violations.push(...toolResultStructureViolations(events));
289
+ for (const { event, lineNo } of events) {
290
+ const loc = { seq: event.seq, lineNo, eventType: event.type };
291
+ violations.push(...engineViolations(event, loc));
292
+ violations.push(...pluginViolations(event, loc));
293
+ }
294
+ if (seqBroken) violations.push(violation('C1', { lineNo: null }, 'seq 缺口/倒退是多写入者并发写的典型后果(N6)'));
295
+
296
+ violations.sort((a, b) => (a.seq ?? -1) - (b.seq ?? -1) || (a.lineNo ?? -1) - (b.lineNo ?? -1));
297
+ const bySeverity = { error: 0, warning: 0, info: 0 };
298
+ const byLayer = {};
299
+ for (const v of violations) {
300
+ bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
301
+ byLayer[v.layer] = (byLayer[v.layer] ?? 0) + 1;
302
+ }
303
+ const result = {
304
+ ok: false, // 不认证(**不是**"broken"——语义见 notAssessable.reason)
305
+ assessable: false,
306
+ structuralOk: bySeverity.error === 0,
307
+ notAssessable,
308
+ violations,
309
+ summary: {
310
+ total: violations.length,
311
+ bySeverity,
312
+ byLayer,
313
+ events: events.length,
314
+ surfaceNodes: null,
315
+ replaceGeneration: null,
316
+ frames: frameInfo?.frames ?? 0,
317
+ compressedBytes: frameInfo?.compressedBytes ?? 0,
318
+ plaintextBytes: frameInfo?.plaintextBytes ?? 0,
319
+ fileVersion: formatVersion,
320
+ hostMaxFileVersion: HOST_MAX_FILE_VERSION,
321
+ assessable: false,
322
+ },
323
+ surface: null,
324
+ };
325
+ result.migration = migrationVerdict(result);
326
+ result.assessmentScope = result.migration.assessmentScope;
327
+ return result;
328
+ }
329
+
330
+ /**
331
+ * 迁移预检结论(S1)——**独立维度**:官方把 v0/v1/v2 升到当前格式会不会拒。
332
+ *
333
+ * 与 `ok` / `loadable` / `resumable` / `compactable` **并列且互不蕴含**:
334
+ * 本工具判 ok **不代表**官方迁移会接受(反之亦然)。只统计 `layer === 'migration'` 的违规
335
+ * (G1/G2),并**显式声明覆盖边界**——未覆盖的官方迁移规则不等于通过。
336
+ *
337
+ * @param {object} result `validateSessionLog()` 的返回值
338
+ */
339
+ export function migrationVerdict(result) {
340
+ const violations = result?.violations ?? [];
341
+ const fileVersion = result?.summary?.fileVersion ?? result?.notAssessable?.fileVersion ?? null;
342
+ const coverage = {
343
+ implemented: [
344
+ 'G1 v0 源:subagent/descriptor.data.version !== 3',
345
+ 'G2 v0 源:事件类型不在官方 v0 dispositions 内(含 ignorable:true)',
346
+ ],
347
+ uncovered: [
348
+ // 2026-09-14 第四轮(最终复核实测):官方 v1→v2 的 turn/start 规则有**两个方向**——
349
+ // "does not close the prior turn"(未闭合)与 "does not open expected turn"(未打开预期轮)。
350
+ // 残余 27 例里有 7 例是后者;类文本原先只写"未闭合" ⇒ 标注与事实不符。此处扩类。
351
+ 'turn/start 未闭合前一轮 / 未打开预期轮(官方:does not close the prior turn / does not open expected turn)',
352
+ 'assistant/attempt 配对/闭合(migration refuses the transformed artifact)',
353
+ 'session/title messageSeqs 越界',
354
+ 'Session inheritedEventCut / 继承切点',
355
+ 'stored log corrupt(SessionFormatError)',
356
+ 'v0→v1 对其余事件的形状拒绝',
357
+ ],
358
+ note: '未覆盖 ≠ 通过:本维度只对 implemented 两条负责。官方判据版本:@deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2',
359
+ };
360
+ if (result?.assessable === false) {
361
+ return { assessable: false, applies: null, ready: null, fileVersion, blocked: [], coverage, assessmentScope: 'none', reason: result?.notAssessable?.reason ?? null };
362
+ }
363
+ const blocked = violations.filter((v) => v.layer === 'migration');
364
+ if (fileVersion !== null && fileVersion === MIGRATION_TARGET_VERSION) {
365
+ return { assessable: true, applies: false, ready: null, fileVersion, targetFileVersion: MIGRATION_TARGET_VERSION, blocked, coverage, assessmentScope: 'full', reason: `被检文件已是当前官方格式(version=${fileVersion}),无需迁移` };
366
+ }
367
+ const applies = fileVersion !== null && fileVersion < MIGRATION_TARGET_VERSION;
368
+ return {
369
+ assessable: true,
370
+ applies,
371
+ ready: applies ? blocked.length === 0 : null,
372
+ fileVersion,
373
+ targetFileVersion: MIGRATION_TARGET_VERSION,
374
+ hostMaxFileVersion: HOST_MAX_FILE_VERSION,
375
+ blocked,
376
+ coverage,
377
+ // 2026-09-14 第四轮(最终复核):待迁移文件(applies=true)**只**被 2 条 G 规则覆盖,
378
+ // 另有 6 类官方迁移规则未覆盖 ⇒ 评估范围只能是 "partial"。工具无法逐文件知道
379
+ // 本文件是否命中未覆盖类(那要先把 6 类实现出来或跑官方迁移),但它**知道**这件事。
380
+ assessmentScope: applies ? 'partial' : 'full',
381
+ };
382
+ }
383
+
384
+ /**
385
+ * 评估范围(S1 收窄后的机器可判字段)。
386
+ * - `'partial'`:被检文件需要迁移(`migration.applies === true`)⇒ 迁移维度只覆盖 2 条规则,
387
+ * 另有 6 类未覆盖;`ok`/`loadable`/`compactable` **不构成**"官方升级会接受"。
388
+ * - `'full'`:被检文件已是当前官方格式(无需迁移)⇒ 迁移维度不适用,结构维度为全集。
389
+ * - `'none'`:本宿主能力不足以评估该文件版本(S2 闸)⇒ 不给任何档位结论。
390
+ * @param {object} result `validateSessionLog()` 的返回值
391
+ * @returns {'full'|'partial'|'none'}
392
+ */
393
+ export function assessmentScope(result) {
394
+ return result?.migration?.assessmentScope
395
+ ?? result?.assessmentScope
396
+ ?? migrationVerdict(result).assessmentScope;
178
397
  }
179
398
 
180
399
  /**
@@ -191,13 +410,30 @@ export function validateSessionLog(log, opts = {}) {
191
410
  *
192
411
  * @param {{ok: boolean, violations: Array, summary: object}} result validateSessionLog 的返回值
193
412
  * @returns {{
194
- * verdict: 'loadable' | 'resumable' | 'compactable' | 'broken',
413
+ * verdict: 'loadable' | 'resumable' | 'compactable' | 'broken' | 'not-assessable',
414
+ * assessable: boolean,
195
415
  * loadable: boolean, resumable: boolean, compactable: boolean,
416
+ * migration: object,
196
417
  * blocking: { loadable: Array, resumable: Array, compactable: Array },
197
418
  * }}
198
419
  */
199
420
  export function resumeVerdict(result) {
200
421
  const { ok, violations = [] } = result;
422
+ // 宿主能力闸(S2):本宿主评估不了 ⇒ 新档位 `not-assessable`(**不是** broken)。
423
+ // 三档 loadable/resumable/compactable 一律 false(不认证),原因在 notAssessable.reason。
424
+ if (result?.assessable === false) {
425
+ return {
426
+ verdict: 'not-assessable',
427
+ assessable: false,
428
+ loadable: false,
429
+ resumable: false,
430
+ compactable: false,
431
+ notAssessable: result.notAssessable ?? null,
432
+ migration: result.migration ?? migrationVerdict(result),
433
+ blocking: { loadable: [], resumable: [], compactable: [] },
434
+ violationsByTier: { structural: [], inbox: [], tokenMeter: [] },
435
+ };
436
+ }
201
437
  // 三档各自的「阻断规则集」——按任务书 §L3 档位定义:
202
438
  // 可加载:结构层(PERSISTENCE/FRAMING)+ 引擎层非 I1/T1/T2/T3/T4 的 error;
203
439
  // 可继续:+ I1(inbox 重放);
@@ -230,9 +466,11 @@ export function resumeVerdict(result) {
230
466
 
231
467
  return {
232
468
  verdict,
469
+ assessable: true,
233
470
  loadable,
234
471
  resumable,
235
472
  compactable,
473
+ migration: result.migration ?? migrationVerdict(result),
236
474
  blocking: {
237
475
  loadable: [...new Set(loadableBlockers)],
238
476
  resumable: resumableBlockers,