dsh-log-contract 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/bin/dsh-log-contract.mjs +165 -0
- package/docs/CONTRACTS.md +209 -0
- package/lib/checks.js +266 -0
- package/lib/contracts.js +267 -0
- package/lib/index.js +10 -0
- package/lib/log-reader.js +179 -0
- package/lib/prewrite.js +155 -0
- package/lib/validate.js +147 -0
- package/package.json +68 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/log-reader.js
|
|
3
|
+
*
|
|
4
|
+
* 会话日志读取层:zstd 帧扫描 → 解压 → 逐行 JSON.parse → 官方
|
|
5
|
+
* `decodeStorageRecord` 展开(chunk 行展开 / 损坏行报错)。
|
|
6
|
+
*
|
|
7
|
+
* 契约来源:
|
|
8
|
+
* - 帧扫描/撕裂尾帧判定:复用本项目审计方法论(scan-seq-gaps.mjs),
|
|
9
|
+
* 帧头布局对齐 zstd 规范(magic 0xFD2FB528、descriptor、block 头)。
|
|
10
|
+
* - 行解码:`@deepseek-ai/dsh-session` 的 `decodeStorageRecord`
|
|
11
|
+
* (lib/index.js:1029,validateRow :922 / expandRow :973)。
|
|
12
|
+
* - 损坏语义:R2 —— chunk 行损坏 = 整段 run 丢失且加载失败(dsh-session
|
|
13
|
+
* lib/index.js:1022-1024 注释明示 fail-loud,无跳过逃生舱)。
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
17
|
+
import { decodeStorageRecord } from '@deepseek-ai/dsh-session';
|
|
18
|
+
|
|
19
|
+
const ZSTD_MAGIC = 0xfd2fb528;
|
|
20
|
+
|
|
21
|
+
/** 扫描 zstd 帧边界;返回 [start, end] 列表与是否出现撕裂尾帧。 */
|
|
22
|
+
export function scanZstdFrames(buf) {
|
|
23
|
+
const frames = [];
|
|
24
|
+
let offset = 0;
|
|
25
|
+
let torn = false;
|
|
26
|
+
while (offset < buf.length) {
|
|
27
|
+
const start = offset;
|
|
28
|
+
if (buf.length - offset < 4 || buf.readUInt32LE(offset) !== ZSTD_MAGIC) {
|
|
29
|
+
torn = true;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
offset += 4;
|
|
33
|
+
if (offset === buf.length) {
|
|
34
|
+
torn = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
const descriptor = buf.readUInt8(offset);
|
|
38
|
+
offset += 1;
|
|
39
|
+
if ((descriptor & 24) !== 0) {
|
|
40
|
+
// 保留位被置位:非法帧头
|
|
41
|
+
return { frames, torn: true, reason: 'reserved-bit' };
|
|
42
|
+
}
|
|
43
|
+
const contentSizeFlag = descriptor >>> 6;
|
|
44
|
+
const singleSegment = (descriptor & 32) !== 0;
|
|
45
|
+
const checksum = (descriptor & 4) !== 0;
|
|
46
|
+
const dictFlag = descriptor & 3;
|
|
47
|
+
const dictBytes = dictFlag === 3 ? 4 : dictFlag;
|
|
48
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : (1 << contentSizeFlag);
|
|
49
|
+
const hdrExtra = (singleSegment ? 0 : 1) + dictBytes + contentSizeBytes;
|
|
50
|
+
if (buf.length - offset < hdrExtra) {
|
|
51
|
+
torn = true;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
offset += hdrExtra;
|
|
55
|
+
let lastBlock = false;
|
|
56
|
+
while (!lastBlock) {
|
|
57
|
+
if (buf.length - offset < 3) {
|
|
58
|
+
torn = true;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
const bh = buf.readUInt32LE(offset);
|
|
62
|
+
offset += 3;
|
|
63
|
+
lastBlock = (bh & 1) !== 0;
|
|
64
|
+
offset += (bh >>> 3) & 0x1fffff;
|
|
65
|
+
}
|
|
66
|
+
if (torn) break;
|
|
67
|
+
if (offset > buf.length) {
|
|
68
|
+
// 块内容越过文件末尾:最后一帧被截断,不能算完整帧
|
|
69
|
+
torn = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
if (checksum) offset += 4;
|
|
73
|
+
frames.push([start, offset]);
|
|
74
|
+
}
|
|
75
|
+
return { frames, torn, reason: torn ? 'incomplete-tail' : undefined };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 把 zstd 多帧拼成完整明文;任一帧解码失败即抛错(N5 单帧全损语义)。 */
|
|
79
|
+
export function decompressZstd(buf) {
|
|
80
|
+
const { frames, torn } = scanZstdFrames(buf);
|
|
81
|
+
if (torn) throw new Error('zstd 尾帧撕裂(incomplete tail frame)——日志可能正在写入或已截断');
|
|
82
|
+
// 逐帧解码后一次性 Buffer.concat:避免每帧一次 concat 的 O(n²) 拷贝
|
|
83
|
+
const parts = new Array(frames.length);
|
|
84
|
+
for (let i = 0; i < frames.length; i++) {
|
|
85
|
+
const [s, e] = frames[i];
|
|
86
|
+
try {
|
|
87
|
+
parts[i] = zstdDecompressSync(buf.subarray(s, e));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
throw new Error(`zstd 帧解码失败 [${s},${e}):${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return Buffer.concat(parts);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 读取并解码一个会话日志文件(.jsonl / .jsonl.zstd / 任意带 zstd 魔数的文件)。
|
|
97
|
+
*
|
|
98
|
+
* @param {string} path 日志文件路径
|
|
99
|
+
* @returns {{
|
|
100
|
+
* header: object|null, headerLine: number|null,
|
|
101
|
+
* rows: Array<{lineNo:number, value:unknown, decoded:Array|null, error:Error|null}>,
|
|
102
|
+
* events: Array<{seq:number, event:object, lineNo:number}>,
|
|
103
|
+
* frameInfo: {frames:number, torn:boolean, compressedBytes:number, plaintextBytes:number},
|
|
104
|
+
* }}
|
|
105
|
+
*/
|
|
106
|
+
export function loadSessionLog(path) {
|
|
107
|
+
const buf = fs.readFileSync(path);
|
|
108
|
+
const isZstd = buf.length >= 4 && buf.readUInt32LE(0) === ZSTD_MAGIC;
|
|
109
|
+
|
|
110
|
+
let plain;
|
|
111
|
+
let frameInfo;
|
|
112
|
+
let loadError = null;
|
|
113
|
+
if (isZstd) {
|
|
114
|
+
try {
|
|
115
|
+
plain = decompressZstd(buf);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
loadError = err;
|
|
118
|
+
plain = Buffer.alloc(0);
|
|
119
|
+
}
|
|
120
|
+
const { frames, torn } = scanZstdFrames(buf);
|
|
121
|
+
frameInfo = { frames: frames.length, torn, compressedBytes: buf.length, plaintextBytes: plain.length };
|
|
122
|
+
} else {
|
|
123
|
+
plain = buf;
|
|
124
|
+
frameInfo = { frames: 0, torn: false, compressedBytes: 0, plaintextBytes: plain.length };
|
|
125
|
+
}
|
|
126
|
+
if (loadError) {
|
|
127
|
+
return {
|
|
128
|
+
header: null,
|
|
129
|
+
headerLine: 0,
|
|
130
|
+
rows: [],
|
|
131
|
+
events: [],
|
|
132
|
+
frameInfo: { ...frameInfo, error: loadError.message },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const text = plain.toString('utf8');
|
|
137
|
+
const lines = text.split('\n');
|
|
138
|
+
|
|
139
|
+
// 第一行 = header
|
|
140
|
+
const headerLine = 0;
|
|
141
|
+
let header = null;
|
|
142
|
+
const headerRaw = lines[0];
|
|
143
|
+
try {
|
|
144
|
+
header = JSON.parse(headerRaw);
|
|
145
|
+
} catch {
|
|
146
|
+
header = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rows = [];
|
|
150
|
+
const events = [];
|
|
151
|
+
for (let i = 1; i < lines.length; i++) {
|
|
152
|
+
const line = lines[i];
|
|
153
|
+
if (line.trim().length === 0) continue; // 帧边界产物(空行)跳过
|
|
154
|
+
const lineNo = i;
|
|
155
|
+
let value;
|
|
156
|
+
try {
|
|
157
|
+
value = JSON.parse(line);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
rows.push({ lineNo, value: null, decoded: null, error: err });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
let decoded;
|
|
163
|
+
try {
|
|
164
|
+
decoded = decodeStorageRecord(value);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
rows.push({ lineNo, value, decoded: null, error: err });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const event of decoded) {
|
|
170
|
+
events.push({ seq: event.seq, event, lineNo });
|
|
171
|
+
}
|
|
172
|
+
rows.push({ lineNo, value, decoded, error: null });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 按 seq 排序(文件顺序即日志顺序,此处防御性排序以便下游契约检查)
|
|
176
|
+
events.sort((a, b) => a.seq - b.seq);
|
|
177
|
+
|
|
178
|
+
return { header, headerLine, rows, events, frameInfo };
|
|
179
|
+
}
|
package/lib/prewrite.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/prewrite.js
|
|
3
|
+
*
|
|
4
|
+
* ★ 写前校验(pre-write validation)——本工具的第一公民。
|
|
5
|
+
*
|
|
6
|
+
* 复盘事故(2026-08-25)第 1 轮失败就是"违约写入没被拦":surface-replace
|
|
7
|
+
* 的 `sourceEventSeqs` 被清空后写入,会话加载直接抛
|
|
8
|
+
* `SessionPersistenceCorruptionError`。如果写入前先校验,会话根本不会被改坏。
|
|
9
|
+
*
|
|
10
|
+
* 本模块把"三层契约"(持久化 / 客户端引擎 / 插件语义)固化为可执行检查:
|
|
11
|
+
* - `createPreWriter({ events }).validateAppend(candidate)` —— 追加写入前校验:
|
|
12
|
+
* 拟写事件在进入日志之前,先与当前日志的折叠状态比对(官方 append 的
|
|
13
|
+
* SurfaceManager.validateNext 同思路:validate first, commit later)。
|
|
14
|
+
* - `createPreWriter({ events }).validateEdit(editedEvents)` —— 帧级手术校验:
|
|
15
|
+
* 修改后的完整事件列表端到端重放(安全修复协议第 2 步"改后确认")。
|
|
16
|
+
*
|
|
17
|
+
* 所有判定复用 `lib/checks.js`(与离线体检同一套逻辑),
|
|
18
|
+
* 保证"体检看到的问题 = 写入前拦下的问题"。
|
|
19
|
+
*/
|
|
20
|
+
import { envelopeViolations, engineViolations, finalFold, isSafeInt, pluginViolations, replaySurface, violation } from './checks.js';
|
|
21
|
+
|
|
22
|
+
/** 把一个"拟写事件"规整为带 seq 的事件;seq 未携带时按追加位置赋值。 */
|
|
23
|
+
function normalizeCandidate(candidate, nextSeq) {
|
|
24
|
+
return candidate.seq === undefined ? { ...candidate, seq: nextSeq } : candidate;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 基于当前日志事件列表建立写前校验器。
|
|
29
|
+
*
|
|
30
|
+
* @param {{ events: Array<object>, baseSeq?: number }} input 当前日志的已解码事件
|
|
31
|
+
* (按日志顺序;无 seq 字段的事件按位置补 seq,用于窗口校验)。
|
|
32
|
+
* @returns {{
|
|
33
|
+
* events: Array, nextSeq: number,
|
|
34
|
+
* validateAppend(candidate, opts?): { ok, violations, stateAfter },
|
|
35
|
+
* validateEdit(editedEvents, opts?): { ok, violations, stateAfter },
|
|
36
|
+
* }}
|
|
37
|
+
*/
|
|
38
|
+
export function createPreWriter(input = {}) {
|
|
39
|
+
const { baseSeq = 0 } = input;
|
|
40
|
+
let events = [...input.events];
|
|
41
|
+
// 窗口校验支持"无 seq 的原始事件列表":按位置补齐 seq 与 time。
|
|
42
|
+
let nextSeq = baseSeq;
|
|
43
|
+
events = events.map((e) => {
|
|
44
|
+
const normalized = e.seq === undefined ? { ...e, seq: nextSeq } : e;
|
|
45
|
+
nextSeq = Math.max(nextSeq, normalized.seq + 1);
|
|
46
|
+
return normalized;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const runChecks = (candidateEvents, tailHint) => {
|
|
50
|
+
const violations = [];
|
|
51
|
+
// E2 —— 全列表 seq 严格连续(含拟写事件);tailHint 时给 append-only 语境
|
|
52
|
+
let expected = baseSeq;
|
|
53
|
+
for (let i = 0; i < candidateEvents.length; i++) {
|
|
54
|
+
const event = candidateEvents[i];
|
|
55
|
+
if (typeof event.seq === 'number' && Number.isSafeInteger(event.seq) && event.seq >= 0) {
|
|
56
|
+
if (event.seq !== expected) {
|
|
57
|
+
const kind = event.seq < expected ? '倒退(backward)' : '缺口(gap)';
|
|
58
|
+
const tail = tailHint !== undefined && i === candidateEvents.length - 1 ? ' —— 只能追加到日志尾部(append-only,N6)' : '';
|
|
59
|
+
violations.push(violation('E2', { seq: event.seq, eventType: event.type }, `seq ${event.seq} 不连续:${kind},期望 ${expected}${tail}`));
|
|
60
|
+
expected = event.seq + 1;
|
|
61
|
+
} else {
|
|
62
|
+
expected = event.seq + 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// E1/E3/E4/E5/E6 + M1 + P1/P2 —— 逐事件
|
|
67
|
+
for (const event of candidateEvents) {
|
|
68
|
+
const loc = { seq: event.seq, lineNo: null, eventType: event.type };
|
|
69
|
+
violations.push(...envelopeViolations(event, loc));
|
|
70
|
+
violations.push(...engineViolations(event, loc));
|
|
71
|
+
violations.push(...pluginViolations(event, loc));
|
|
72
|
+
}
|
|
73
|
+
// S1–S7 —— 与官方同语义的增量重放(含拟写事件)
|
|
74
|
+
const replay = replaySurface(candidateEvents.map((event) => ({ event })));
|
|
75
|
+
violations.push(...replay.violations);
|
|
76
|
+
// S8 —— 官方 foldSurface 终验
|
|
77
|
+
const folded = finalFold(candidateEvents);
|
|
78
|
+
if (folded.error) {
|
|
79
|
+
violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
|
|
80
|
+
}
|
|
81
|
+
const bySeverity = { error: 0, warning: 0, info: 0 };
|
|
82
|
+
for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
|
|
83
|
+
return {
|
|
84
|
+
ok: bySeverity.error === 0,
|
|
85
|
+
violations,
|
|
86
|
+
bySeverity,
|
|
87
|
+
surface: folded.surface ?? { nodes: replay.nodes, replacements: [] },
|
|
88
|
+
nextSeq: candidateEvents.length ? candidateEvents[candidateEvents.length - 1].seq + 1 : baseSeq,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
events,
|
|
94
|
+
get nextSeq() {
|
|
95
|
+
return nextSeq;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 追加写入前校验:candidate 将以 nextSeq 进入日志。
|
|
100
|
+
* candidate 可携带 seq(必须等于 nextSeq)或不携带(自动赋 nextSeq)。
|
|
101
|
+
*/
|
|
102
|
+
validateAppend(candidate, opts = {}) {
|
|
103
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
violations: [violation('E1', {}, '拟写事件必须是普通对象(会话事件信封)')],
|
|
107
|
+
bySeverity: { error: 1, warning: 0, info: 0 },
|
|
108
|
+
surface: null,
|
|
109
|
+
nextSeq,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const normalized = normalizeCandidate(candidate, nextSeq);
|
|
113
|
+
const after = [...events, normalized];
|
|
114
|
+
const result = runChecks(after, nextSeq); result.stateAfter = {
|
|
115
|
+
events: after,
|
|
116
|
+
nextSeq: result.nextSeq,
|
|
117
|
+
surfaceNodes: result.surface?.nodes ?? [],
|
|
118
|
+
};
|
|
119
|
+
return result;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 帧级手术校验:editedEvents 是"写入后将存在的完整事件列表"。
|
|
124
|
+
* 用于安全修复协议第 2 步(改后确认):必须与"改前基线
|
|
125
|
+
* (validateSessionLog 通过)"双绿才允许落盘。
|
|
126
|
+
*/
|
|
127
|
+
validateEdit(editedEvents) {
|
|
128
|
+
if (!Array.isArray(editedEvents)) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
violations: [violation('E1', {}, 'editedEvents 必须是事件数组')],
|
|
132
|
+
bySeverity: { error: 1, warning: 0, info: 0 },
|
|
133
|
+
surface: null,
|
|
134
|
+
nextSeq,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const result = runChecks(editedEvents, undefined);
|
|
138
|
+
result.stateAfter = {
|
|
139
|
+
events: editedEvents,
|
|
140
|
+
nextSeq: result.nextSeq,
|
|
141
|
+
surfaceNodes: result.surface?.nodes ?? [],
|
|
142
|
+
};
|
|
143
|
+
return result;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 一站式便利:加载会话日志 → 建立写前校验器。
|
|
150
|
+
* @param {import('./log-reader.js').loadSessionLog} log `loadSessionLog()` 结果
|
|
151
|
+
*/
|
|
152
|
+
export function preWriterFromLog(log) {
|
|
153
|
+
const events = (log.events ?? []).map((e) => e.event);
|
|
154
|
+
return createPreWriter({ events });
|
|
155
|
+
}
|
package/lib/validate.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-log-contract · lib/validate.js
|
|
3
|
+
*
|
|
4
|
+
* 离线体检引擎:对 `loadSessionLog` 的结果逐条跑契约规则,产出违规报告。
|
|
5
|
+
*
|
|
6
|
+
* 判定哲学(复盘事故 §四-1):**持久化层以官方 `foldSurface` 不抛为通过**,
|
|
7
|
+
* 但为定位问题,先用与官方同语义的增量重放做逐事件归因(S1–S7),
|
|
8
|
+
* 再跑官方 foldSurface 作终验(S8)——两套都绿才算过。
|
|
9
|
+
*/
|
|
10
|
+
import { ruleById } from './contracts.js';
|
|
11
|
+
import {
|
|
12
|
+
CHUNK_ROW_TYPES,
|
|
13
|
+
envelopeViolations,
|
|
14
|
+
engineViolations,
|
|
15
|
+
finalFold,
|
|
16
|
+
isSafeInt,
|
|
17
|
+
pluginViolations,
|
|
18
|
+
replaySurface,
|
|
19
|
+
violation,
|
|
20
|
+
} from './checks.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 对会话日志执行全量离线体检。
|
|
24
|
+
*
|
|
25
|
+
* @param {object} log `loadSessionLog()` 的返回值
|
|
26
|
+
* @param {{ baseSeq?: number }} [opts]
|
|
27
|
+
* @returns {{
|
|
28
|
+
* ok: boolean,
|
|
29
|
+
* violations: Array,
|
|
30
|
+
* summary: object,
|
|
31
|
+
* surface: object,
|
|
32
|
+
* }}
|
|
33
|
+
*/
|
|
34
|
+
export function validateSessionLog(log, opts = {}) {
|
|
35
|
+
const { baseSeq = 0 } = opts;
|
|
36
|
+
const violations = [];
|
|
37
|
+
const { header, headerLine, rows, events, frameInfo } = log;
|
|
38
|
+
|
|
39
|
+
// ── Z · 帧结构 ─────────────────────────────────────────────────────────
|
|
40
|
+
if (frameInfo?.torn) {
|
|
41
|
+
violations.push(violation('Z1', { lineNo: null }, `zstd 尾帧撕裂:可能是写入中的 in-flight 帧或文件被截断(帧数 ${frameInfo.frames})`));
|
|
42
|
+
}
|
|
43
|
+
if (frameInfo?.error) {
|
|
44
|
+
violations.push(violation('Z2', { lineNo: null }, frameInfo.error));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── H · header ─────────────────────────────────────────────────────────
|
|
48
|
+
if (header === null) {
|
|
49
|
+
violations.push(violation('H1', { lineNo: headerLine }, '首行不是合法 JSON —— 整个会话不可读'));
|
|
50
|
+
} else {
|
|
51
|
+
if (header.type !== 'session') {
|
|
52
|
+
violations.push(violation('H1', { lineNo: headerLine }, `首行 type 必须为 "session"(实际 ${String(header.type)})`));
|
|
53
|
+
}
|
|
54
|
+
if (header.version !== 0) {
|
|
55
|
+
violations.push(violation('H2', { lineNo: headerLine }, `header.version 必须为 0(实际 ${String(header.version)})——格式版本演进无迁移机制(F1)`));
|
|
56
|
+
}
|
|
57
|
+
if (typeof header.id !== 'string' || header.id === '') {
|
|
58
|
+
violations.push(violation('H2', { lineNo: headerLine }, 'header.id 必须为非空字符串'));
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isSafeInteger(header.createdAt) || header.createdAt < 0) {
|
|
61
|
+
violations.push(violation('H2', { lineNo: headerLine }, 'header.createdAt 必须为非负安全整数'));
|
|
62
|
+
}
|
|
63
|
+
if (header.cwd !== undefined && (typeof header.cwd !== 'string' || !header.cwd.startsWith('/'))) {
|
|
64
|
+
violations.push(violation('H2', { lineNo: headerLine }, 'header.cwd 若存在必须为绝对路径'));
|
|
65
|
+
}
|
|
66
|
+
if (header.origin !== undefined && header.origin !== 'subagent') {
|
|
67
|
+
violations.push(violation('H2', { lineNo: headerLine }, `header.origin 只能为 "subagent"(实际 ${String(header.origin)})`));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── R · 存储行 ─────────────────────────────────────────────────────────
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
if (row.error && row.value === null) {
|
|
74
|
+
violations.push(violation('R1', { lineNo: row.lineNo }, '该行不是合法 JSON(损坏行)'));
|
|
75
|
+
} else if (row.error && CHUNK_ROW_TYPES.has(row.value?.type)) {
|
|
76
|
+
violations.push(violation('R2', { lineNo: row.lineNo }, `chunk 行 "${row.value.type}" 损坏:${row.error.message} —— 整段 run 丢失且加载失败(fail-loud,无跳过逃生舱)`));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── E · 事件信封 + seq 连续性 ──────────────────────────────────────────
|
|
81
|
+
let expectedSeq = baseSeq;
|
|
82
|
+
let seqBroken = false;
|
|
83
|
+
for (const { event, lineNo } of events) {
|
|
84
|
+
const loc = { seq: event.seq, lineNo, eventType: event.type };
|
|
85
|
+
violations.push(...envelopeViolations(event, loc));
|
|
86
|
+
if (typeof event.seq === 'number' && Number.isSafeInteger(event.seq) && event.seq >= 0) {
|
|
87
|
+
if (event.seq !== expectedSeq) {
|
|
88
|
+
const kind = event.seq < expectedSeq ? '倒退(backward)' : '缺口(gap)';
|
|
89
|
+
violations.push(violation('E2', loc, `seq ${event.seq} 不连续:${kind},期望 ${expectedSeq} —— 违反单写入者假设(N6)`));
|
|
90
|
+
seqBroken = true;
|
|
91
|
+
expectedSeq = event.seq + 1;
|
|
92
|
+
} else {
|
|
93
|
+
expectedSeq = event.seq + 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── S · surface 增量重放(归因)+ 官方 foldSurface 终验 ────────────────
|
|
99
|
+
const replay = replaySurface(events);
|
|
100
|
+
violations.push(...replay.violations);
|
|
101
|
+
|
|
102
|
+
const folded = finalFold(events.map((e) => e.event));
|
|
103
|
+
if (folded.error) {
|
|
104
|
+
violations.push(violation('S8', { lineNo: null }, `官方 foldSurface 重放失败:${folded.error.message} —— 会话加载会被拒(SessionPersistenceCorruptionError)`));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── M / P ──────────────────────────────────────────────────────────────
|
|
108
|
+
for (const { event, lineNo } of events) {
|
|
109
|
+
const loc = { seq: event.seq, lineNo, eventType: event.type };
|
|
110
|
+
violations.push(...engineViolations(event, loc));
|
|
111
|
+
violations.push(...pluginViolations(event, loc));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── C · 并发(仅当出现 seq 破坏时给出解释性告警)─────────────────────
|
|
115
|
+
if (seqBroken) {
|
|
116
|
+
violations.push(violation('C1', { lineNo: null }, 'seq 缺口/倒退是多写入者(≥2 个 Host 进程共享同一 session 目录)并发写的典型后果;离线体检无法观测竞态本身,但此痕迹需人工核查(N6)'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 汇总 ───────────────────────────────────────────────────────────────
|
|
120
|
+
violations.sort((a, b) => (a.seq ?? -1) - (b.seq ?? -1) || (a.lineNo ?? -1) - (b.lineNo ?? -1));
|
|
121
|
+
const bySeverity = { error: 0, warning: 0, info: 0 };
|
|
122
|
+
const byLayer = {};
|
|
123
|
+
for (const v of violations) {
|
|
124
|
+
bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
|
|
125
|
+
byLayer[v.layer] = (byLayer[v.layer] ?? 0) + 1;
|
|
126
|
+
}
|
|
127
|
+
const summary = {
|
|
128
|
+
total: violations.length,
|
|
129
|
+
bySeverity,
|
|
130
|
+
byLayer,
|
|
131
|
+
events: events.length,
|
|
132
|
+
surfaceNodes: replay.nodes.length,
|
|
133
|
+
replaceGeneration: replay.replaceGeneration,
|
|
134
|
+
frames: frameInfo?.frames ?? 0,
|
|
135
|
+
compressedBytes: frameInfo?.compressedBytes ?? 0,
|
|
136
|
+
plaintextBytes: frameInfo?.plaintextBytes ?? 0,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
ok: bySeverity.error === 0,
|
|
141
|
+
violations,
|
|
142
|
+
summary,
|
|
143
|
+
surface: folded.surface ?? { nodes: replay.nodes, replacements: [] },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { ruleById };
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-log-contract",
|
|
3
|
+
"description": "日志契约守护 — DSH session log contract guard: offline health check (CLI) + pre-write validation for DeepSeek Harness session logs",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"packageManager": "pnpm@11.7.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dsh-log-contract": "./bin/dsh-log-contract.mjs"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"default": "./lib/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./contracts": {
|
|
16
|
+
"default": "./lib/contracts.js"
|
|
17
|
+
},
|
|
18
|
+
"./prewrite": {
|
|
19
|
+
"default": "./lib/prewrite.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib/**/*.js",
|
|
25
|
+
"bin/**/*.mjs",
|
|
26
|
+
"docs/**/*.md",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"check": "node scripts/check-syntax.mjs",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"prepublishOnly": "pnpm check && pnpm test"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"dsh",
|
|
37
|
+
"deepseek-harness",
|
|
38
|
+
"session",
|
|
39
|
+
"log",
|
|
40
|
+
"contract",
|
|
41
|
+
"validator",
|
|
42
|
+
"prewrite",
|
|
43
|
+
"jsonl",
|
|
44
|
+
"zstd",
|
|
45
|
+
"cli"
|
|
46
|
+
],
|
|
47
|
+
"author": "OfferKuai <contact@offerkuai.com> (https://www.offerkuai.com)",
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/yamingmou/dsh-log-contract.git"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/yamingmou/dsh-log-contract/issues"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/yamingmou/dsh-log-contract#readme",
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=22"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.7"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.7",
|
|
65
|
+
"esbuild": "0.28.2",
|
|
66
|
+
"vitest": "4.1.11"
|
|
67
|
+
}
|
|
68
|
+
}
|