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/README.md +31 -0
- package/README.zh.md +27 -0
- package/bin/dsh-log-contract.mjs +185 -64
- package/docs/CONTRACTS.md +100 -20
- package/lib/checks.js +139 -23
- package/lib/compat.js +282 -0
- package/lib/contracts.js +44 -4
- package/lib/index.js +3 -2
- package/lib/legacy-fold.js +177 -0
- package/lib/log-reader.js +8 -4
- package/lib/prewrite.js +33 -15
- package/lib/validate.js +250 -12
- package/lib/vocab.js +193 -0
- package/package.json +4 -4
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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 },
|
|
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
|
|
192
|
+
const edited = editedEvents.map(normalizeEventSeqRanges);
|
|
193
|
+
const result = runChecks(edited, undefined);
|
|
176
194
|
result.stateAfter = {
|
|
177
|
-
events:
|
|
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
|
|
6
|
+
* 判定哲学(复盘事故 §四-1):**持久化层以 fold 不抛为通过**,
|
|
7
7
|
* 但为定位问题,先用与官方同语义的增量重放做逐事件归因(S1–S7),
|
|
8
|
-
*
|
|
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,
|
|
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
|
|
65
|
-
violations.push(violation('H2', { lineNo: headerLine }, `header.version
|
|
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 },
|
|
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
|
-
|
|
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,
|