dsh-log-contract 0.3.13 → 0.3.14

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
@@ -18,12 +18,41 @@
18
18
  * 保证"体检看到的问题 = 写入前拦下的问题"。
19
19
  */
20
20
  import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, turnEndReasonViolations, violation, wireViolations } from './checks.js';
21
- import { resolveFormatVersion } from './vocab.js';
21
+ import { resolveFormatVersion, LEGACY_FORMAT_MAX_VERSION } from './vocab.js';
22
22
  import { normalizeEventSeqRanges } from './compat.js';
23
23
 
24
- /** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
25
- function isKnownMarkerCandidate(event) {
26
- return Boolean(event) && event.type === 'assistant/message' && event.surfaceOp && event.surfaceOp !== 'append' && event.data?.editor !== undefined;
24
+ /**
25
+ * retrace 历史 marker 的 id 前缀(旧载体:`assistant/message` replace + `data.editor`)。
26
+ * retrace `MARKER_ID_PREFIX`(`retrace`/`message-editor`——后者是插件改名前的旧前缀)对齐。
27
+ */
28
+ const LEGACY_MARKER_ID_PREFIXES = ['retrace', 'message-editor'];
29
+
30
+ /**
31
+ * **可识别的历史 retrace 载体**(R-C,2026-09-14 独立复核:白名单收窄)。
32
+ *
33
+ * 旧实现只判"任意 `data.editor` 存在" ⇒ **任何**写 `assistant/message` replace + `data.editor`
34
+ * 的第三方插件都能领 T1 豁免(复核判定:"设计粗糙的豁口")。收窄为四条同时成立:
35
+ * ① 类型/操作:`assistant/message` + replace;
36
+ * ② 载体标记:`data.editor !== undefined`;
37
+ * ③ **身份**:`data.message.id` 带 retrace 历史 marker 前缀(`retrace-*` / `message-editor-*`);
38
+ * ④ **形状**:`editor.targetSeq === (op.start ?? op.startSeq)`(retrace 自己的写前断言钉住的形状)。
39
+ * 任一不满足 → 不是可识别的 retrace 历史 marker,不降级(保持 error)。
40
+ *
41
+ * @returns {{prefix:string,id:string,targetSeq:number,seq:unknown}|null}
42
+ */
43
+ export function legacyMarkerKindOf(event) {
44
+ if (!event || event.type !== 'assistant/message') return null;
45
+ const op = event.surfaceOp;
46
+ if (!op || typeof op !== 'object' || op.op !== 'replace') return null;
47
+ const editor = event.data?.editor;
48
+ if (editor === undefined || editor === null || typeof editor !== 'object') return null;
49
+ const id = event.data?.message?.id;
50
+ if (typeof id !== 'string') return null;
51
+ const prefix = LEGACY_MARKER_ID_PREFIXES.find((p) => id.startsWith(`${p}-`));
52
+ if (!prefix) return null;
53
+ const start = op.start ?? op.startSeq;
54
+ if (editor.targetSeq !== start) return null;
55
+ return { prefix, id, targetSeq: editor.targetSeq, seq: event.seq };
27
56
  }
28
57
 
29
58
  /** 把一个"拟写事件"规整为带 seq 的事件;seq 未携带时按追加位置赋值。 */
@@ -106,15 +135,31 @@ export function createPreWriter(input = {}) {
106
135
  // assistant/message 配对失败保持 error。历史已有事件的 T1 归属离线体检
107
136
  // (check),不在这里重复拦截(否则历史 marker 会让后续编辑全部被拒)。
108
137
  const lastCandidate = candidateEvents[candidateEvents.length - 1];
138
+ const legacyKind = legacyMarkerKindOf(lastCandidate);
139
+ // R-C 版本门(2026-09-14 独立复核):降级**只对 ≤v2 文件**。
140
+ // 依据:新载体(`user/message` + `data.id`,retrace 0.4.26)根本不带 `data.editor`,在 v3 上
141
+ // 降级**救不回任何写入**(v3 禁 assistant/message 带 provenance ⇒ S8 兜住) —— 留在 v3 上
142
+ // 只会掩盖 T1 的真实原因。v0/v1/v2 才是有价值的作用域(回放/重写历史形态 marker)。
143
+ const legacyDowngradeAllowed = formatVersion <= LEGACY_FORMAT_MAX_VERSION;
109
144
  for (const t1 of tokenMeterViolations(candidateEvents.map((event) => ({ event })))) {
110
145
  if (t1.id !== 'T1' || t1.seq !== lastCandidate?.seq) continue;
111
- if (isKnownMarkerCandidate(lastCandidate)) {
112
- violations.push({ ...t1, severity: 'warning', message: `${t1.message}(已知 retrace marker 设计债:压缩前需 doctor 清理)` });
146
+ if (legacyKind && legacyDowngradeAllowed) {
147
+ // 降级**可见**(R-C):违规里带 markerKind/id/targetSeq,并由结果字段 `legacyMarkerDebt`
148
+ // 显式带出;入口(CLI)打印"压缩前需一次性清理",不再"记了没人看"。
149
+ violations.push({
150
+ ...t1,
151
+ severity: 'warning',
152
+ markerKind: legacyKind.prefix,
153
+ markerId: legacyKind.id,
154
+ targetSeq: legacyKind.targetSeq,
155
+ message: `${t1.message}(已知历史 retrace marker 设计债:${legacyKind.prefix} 载体 id=${legacyKind.id} targetSeq=${legacyKind.targetSeq};`
156
+ + `仅对 v≤${LEGACY_FORMAT_MAX_VERSION} 文件降级。压缩前需一次性清理:fix --neutralize-legacy-markers)`,
157
+ });
113
158
  } else {
114
159
  violations.push(t1);
115
160
  }
116
161
  }
117
- // T3/T4 —— 渲染层(2026-09-02 1e99e1ff 白屏)。**error 级拒绝**:
162
+ // T3/T4 —— 渲染层(2026-09-02 渲染层白屏事故)。**error 级拒绝**:
118
163
  // - T4:拟写事件(step/start|step/end|assistant/message)turn 缺失 → 客户端
119
164
  // 渲染死循环白屏(D8),写入前直接拦下(防再犯:任何写 turn:null 的 marker);
120
165
  // - T3:拟写事件引入 step 节点 key 冲突(同 turn 同 step 的 step/start 重复)→
@@ -127,17 +172,23 @@ export function createPreWriter(input = {}) {
127
172
  if (v.seq !== lastCandidate?.seq) continue;
128
173
  violations.push(v);
129
174
  }
130
- // T5 —— 拟写 turn/end 缺 reason.kind → error 拒绝(1f4d986e 防再犯)
175
+ // T5 —— 拟写 turn/end 缺 reason.kind → error 拒绝(malformed turn/end 防再犯)
131
176
  for (const v of turnEndReasonViolations(candidateEvents.map((event) => ({ event })))) {
132
177
  if (v.seq !== lastCandidate?.seq) continue;
133
178
  violations.push(v);
134
179
  }
135
180
  const bySeverity = { error: 0, warning: 0, info: 0 };
136
181
  for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
182
+ // R-C「让降级可见」:把"本次写入沿用了历史 marker 形态(债)"作为**结构化字段**带出,
183
+ // 入口据此打印"压缩前需一次性清理"。降级不再只是 violations 里的一句 warning。
184
+ const legacyDebt = legacyKind && legacyDowngradeAllowed && bySeverity.error === 0
185
+ ? { kind: legacyKind.prefix, id: legacyKind.id, targetSeq: legacyKind.targetSeq, seq: lastCandidate?.seq ?? null, formatVersion }
186
+ : null;
137
187
  return {
138
188
  ok: bySeverity.error === 0,
139
189
  violations,
140
190
  bySeverity,
191
+ legacyMarkerDebt: legacyDebt,
141
192
  surface: folded.surface ?? { nodes: replay.nodes, replacements: [] },
142
193
  nextSeq: candidateEvents.length ? candidateEvents[candidateEvents.length - 1].seq + 1 : baseSeq,
143
194
  };
package/lib/repair.js CHANGED
@@ -123,8 +123,8 @@ export function strictScanText(text) {
123
123
  * 裁剪 assistant/message 的跨 step sourceEventSeqs(2026-08-30 第二类事故)。
124
124
  *
125
125
  * 现象:DSH 的 resend/regenerate 在 agent 仍开着 step 时被触发,会把旧 step 的
126
- * assistant/chunk 全部引用进新 assistant/message 的 sourceEventSeqs(526f1835
127
- * seq 936047:sourceEventSeqs 覆盖 turn 54 的 step 7/8/9 三段)。token-meter
126
+ * assistant/chunk 全部引用进新 assistant/message 的 sourceEventSeqs(实测:
127
+ * sourceEventSeqs 覆盖 turn 54 的 step 7/8/9 三段)。token-meter
128
128
  * 要求每个 source chunk 与消息同 turn/step(dsh-token-meter lib/index.js:645,
129
129
  * `belongs to another step`)→ 同样刷屏压垮 host。
130
130
  *
@@ -191,7 +191,7 @@ export function clipCrossStepSourcesText(text) {
191
191
  * @param {string} text - JSONL 全文(含 header 行)。
192
192
  * @returns {{ text: string, neutralized: number, seqs: Array<number> }}
193
193
  */
194
- export function neutralizeMarkersText(text) {
194
+ export function neutralizeMarkersText(text, { onlyLegacy = false } = {}) {
195
195
  const parts = text.split('\n');
196
196
  const seqs = [];
197
197
  let neutralized = 0;
@@ -209,6 +209,11 @@ export function neutralizeMarkersText(text) {
209
209
  if (v.data?.turn != null || v.data?.step != null) continue;
210
210
  const id = v.data?.message?.id;
211
211
  if (typeof id !== 'string' || !MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`))) continue;
212
+ // R-C 一次性根治路径(`--neutralize-legacy-markers`):只动**历史载体**
213
+ // (`assistant/message` + `data.editor`);新载体是 `user/message` + `data.id`,
214
+ // 本来就不在这个分支里(类型不符),这里额外的判据是"必须有 editor",
215
+ // 便于把"清历史债"与"泛化中和"区分开、也让报告口径可核对。
216
+ if (onlyLegacy && v.data?.editor === undefined) continue;
212
217
  const seq = v.seq;
213
218
  delete v.surfaceOp;
214
219
  delete v.sourceEventSeqs;
@@ -1153,7 +1158,10 @@ export function repairSession(file, opts = {}) {
1153
1158
  let plain;
1154
1159
  if (isZstd) {
1155
1160
  try {
1156
- plain = decompressZstd(buf).toString('utf8');
1161
+ // R-B 边界:**写路径不吃撕裂尾帧**。`check`(只读)按宿主语义恢复撕裂尾帧;但
1162
+ // `fix` 会回写文件,若尾帧是**活动会话正在写入**的部分,回写会把它截掉 ——
1163
+ // 与宿主"由持有租约的会话自己截断"(:226-228)不同责。故这里显式 strict。
1164
+ plain = decompressZstd(buf, { allowTorn: false }).toString('utf8');
1157
1165
  } catch (err) {
1158
1166
  return { file, ok: false, issues: [{ kind: 'zstd-decode', detail: String(err.message ?? err) }], removed: 0, renumbered: 0, applied: false };
1159
1167
  }
@@ -1194,7 +1202,16 @@ export function repairSession(file, opts = {}) {
1194
1202
  applyFix('markers', r, `移除 ${r.removed} 个 retrace/message-editor marker(重编号 ${r.renumbered} 行)`);
1195
1203
  }
1196
1204
  let neutralized = 0;
1197
- const neutralizedSeqs = [];
1205
+ let neutralizedSeqs = [];
1206
+ if (opts.neutralizeLegacyMarkers) {
1207
+ const r = neutralizeMarkersText(plain, { onlyLegacy: true });
1208
+ if (r.neutralized > 0) {
1209
+ plain = r.text;
1210
+ issues.push({ kind: 'neutralize-legacy-markers', detail: `一次性中和 ${r.neutralized} 个**历史载体** retrace marker(assistant/message + data.editor;type→retrace/marker + ignorable:true,删除 surfaceOp/sourceEventSeqs,seq/行数不变)→ 历史 token-meter 配对债根治(此后写前校验不再需要那条 ≤v2 降级白名单)` });
1211
+ }
1212
+ neutralized = r.neutralized;
1213
+ neutralizedSeqs = r.seqs;
1214
+ }
1198
1215
  if (opts.neutralize) {
1199
1216
  const r = neutralizeMarkersText(plain);
1200
1217
  neutralized = r.neutralized;
package/lib/validate.js CHANGED
@@ -12,6 +12,9 @@
12
12
  import { ruleById } from './contracts.js';
13
13
  import { normalizeEventSeqRanges } from './compat.js';
14
14
  import { HOST_MAX_FILE_VERSION, MIGRATION_TARGET_VERSION, hostCapability, isAssessableFileVersion } from './vocab.js';
15
+ import { detectSupport } from './version-support.js';
16
+ import { runHostProbes } from './host-probes.js';
17
+ import { SOURCE_DRIFT } from './contracts.js';
15
18
  import {
16
19
  CHUNK_ROW_TYPES,
17
20
  envelopeViolations,
@@ -219,6 +222,12 @@ export function validateSessionLog(log, opts = {}) {
219
222
  summary,
220
223
  surface: folded.surface ?? { nodes: replay.nodes, replacements: [] },
221
224
  };
225
+ // 版本支持面(2026-09-14 用户要求第 1 条):宿主版本 vs 测试基线 + 被检文件格式版本,
226
+ // 给出 warnings/readOnly。只读档由入口(prewrite/fix)强制执行。
227
+ result.support = detectSupport({ header, events });
228
+ // R-D 漂移检测:行为探针(宿主自己的运行时函数当 oracle)+ R-F 出处/前提漂移清单。
229
+ result.probes = hostProbes();
230
+ result.drift = driftOf(result.probes);
222
231
  result.migration = migrationVerdict(result);
223
232
  result.assessmentScope = result.migration.assessmentScope;
224
233
  return result;
@@ -322,11 +331,53 @@ function unassessableResult({ header, headerLine, rows, events, frameInfo, baseS
322
331
  },
323
332
  surface: null,
324
333
  };
334
+ result.support = detectSupport({ header, events });
335
+ result.probes = hostProbes();
336
+ result.drift = driftOf(result.probes);
325
337
  result.migration = migrationVerdict(result);
326
338
  result.assessmentScope = result.migration.assessmentScope;
327
339
  return result;
328
340
  }
329
341
 
342
+ /** 行为探针只跑一次(宿主在同一进程内不变)。 */
343
+ let probesMemo;
344
+ function hostProbes() {
345
+ if (probesMemo === undefined) {
346
+ try {
347
+ probesMemo = runHostProbes();
348
+ } catch (err) {
349
+ probesMemo = { hostPackage: 'unknown', sessionFormatVersion: null, knownTypes: 0, verified: false, probes: [], unverifiedRules: ['<probe-run-failed>'], error: String(err?.message ?? err) };
350
+ }
351
+ }
352
+ return probesMemo;
353
+ }
354
+
355
+ /**
356
+ * R-D/R-F 合并出的**漂移报告**(`check` 抬头与 `--json` 共用)。
357
+ * · `unverifiedRules` —— 行为探针在本宿主上**不成立**的规则(宿主语义与规则假设不符);
358
+ * · `driftedSources` —— 出处停在 rc.7、未在 0.1.5 上复核(R-F,机读清单);
359
+ * · `premiseStale` —— 判定前提已不成立/无法判定(复核 §1);
360
+ * · `fixApplyBlocked` —— 只要任一非空,就**不得据此跑 `fix --apply`**(R-E)。
361
+ */
362
+ export function driftOf(probes) {
363
+ const p = probes ?? hostProbes();
364
+ const unverifiedRules = [...new Set(p.unverifiedRules ?? [])];
365
+ const driftedSources = [...SOURCE_DRIFT.drifted];
366
+ const premiseStale = [...SOURCE_DRIFT.premiseStale];
367
+ return {
368
+ probeVerified: p.verified === true,
369
+ unverifiedRules,
370
+ driftedSources,
371
+ premiseStale,
372
+ undecidable: [...SOURCE_DRIFT.undecidable],
373
+ // `fix --apply` 的**硬前提**只看行为探针(宿主语义与规则假设不一致时才禁止写入类动作);
374
+ // 出处/前提清单(R-F)是**已知文档债**,会显式告警但不阻断修复流程 —— 否则只要历史
375
+ // 清单非空,"修一个坏会话"这条主路径就永久不可用(见报告"我未照做之处")。
376
+ fixApplyBlocked: unverifiedRules.length > 0,
377
+ note: SOURCE_DRIFT.note,
378
+ };
379
+ }
380
+
330
381
  /**
331
382
  * 迁移预检结论(S1)——**独立维度**:官方把 v0/v1/v2 升到当前格式会不会拒。
332
383
  *
@@ -349,7 +400,7 @@ export function migrationVerdict(result) {
349
400
  // 2026-09-15 第五轮:`turn/start` 试做后在**真实语料**上被判定为"应用对象错"——
350
401
  // 官方状态机(assertReleasedArtifactRelationships)由 v1→v2 在**变换后的 v1/v2 artifact**
351
402
  // 上调用(v1-to-v2/lib/index.js:104),带 cut/继承切点处理;在原始 v0 上照抄会在已 seed
352
- // 的会话上狂报(样本 session-62c5b531:0.1.5 链并不以该规则拒它,原始 v0 上会报 19 条)。
403
+ // 的会话上狂报(实测样本:0.1.5 链并不以该规则拒它,原始 v0 上会报 19 条)。
353
404
  // ⇒ 忠实复现需先做 v0→v1→v2 变换;本轮不做,保持未覆盖。
354
405
  'turn/start 闭合/预期轮(官方 v1→v2 在**变换后** artifact 上判:does not close the prior turn / does not open expected turn)',
355
406
  'assistant/attempt 配对/闭合(migration refuses the transformed artifact)',
@@ -0,0 +1,148 @@
1
+ /**
2
+ * dsh-log-contract · lib/version-support.js —— **版本支持面**:测试基线 + 运行时检测/告警
3
+ * (2026-09-14 用户要求第 1 条:把"哪些版本被验证过"写成明文,并在运行时检测报告)。
4
+ *
5
+ * 背景(外部质疑 + 事实核对):
6
+ * - 事实①:本包 `peerDependencies` = `^0.1.0-rc.7 || ^0.1.5-rc.1`,devDependency 与开发机
7
+ * 宿主实装都是 `0.1.5-rc.1` ⇒ **覆盖到了**;且本规则集在 v3 会话上实测有效
8
+ * (在真实 v3 文件上准确拒绝了伪造的 `seq: 0`,见 E2/S6/S8 用例)。
9
+ * - 事实②:"**哪些版本被验证过**"此前只散落在注释与 CHANGELOG 里,**没有一处明文**,
10
+ * 外部无法核对 ⇒ 本模块把**测试基线**变成一个可读、可比对、会告警的常量。
11
+ *
12
+ * 三条运行时规则("不闷着按旧规则判"):
13
+ * 1. 宿主 `@deepseek-ai/dsh-session` 版本**比基线新** ⇒ 明确告警(规则集未在该版本上验证);
14
+ * 2. 被检**会话文件格式版本**不在已知集合 {0,1,2,3} 内 ⇒ 报"未验证格式"并**按只读处理**
15
+ * (不做写入前判定、不做修复);
16
+ * 3. 两者都可解析时,给出一行可核对的摘要(宿主 / 文件格式 / 基线)。
17
+ */
18
+ import { hostPackageVersion, resolveFormatVersionDetailed, HOST_MAX_FILE_VERSION } from './vocab.js';
19
+
20
+ /**
21
+ * **测试基线**(唯一权威):本规则集在下列宿主 / 会话格式版本上被验证。
22
+ * 改这里必须同步 README(中英)与 docs/CONTRACTS.md 头部(生成器模板)。
23
+ */
24
+ export const TESTED_BASELINE = Object.freeze({
25
+ hostPackage: '@deepseek-ai/dsh-session',
26
+ /** 开发机与 CI 实装宿主版本(devDependency 同值)。 */
27
+ hostVersion: '0.1.5-rc.1',
28
+ /** 声明的 peer 范围(允许安装,但未逐一验证)。 */
29
+ peerRange: '^0.1.0-rc.7 || ^0.1.5-rc.1',
30
+ /** 会话格式版本基线(= 宿主 SESSION_FORMAT_VERSION)。 */
31
+ sessionFormatVersion: 3,
32
+ /** 已知/受支持的会话格式版本(0/1/2 由本包 vendored 词表 + 本地等价折叠支持)。 */
33
+ knownFormatVersions: Object.freeze([0, 1, 2, 3]),
34
+ note: '旧格式(0/1/2)由本包自带的 vendored 词表与 legacyFoldSurface 支持,与宿主版本无关;'
35
+ + 'v3 用运行时词表/官方 foldSurface ⇒ 需要宿主 SESSION_FORMAT_VERSION ≥ 3。',
36
+ });
37
+
38
+ /**
39
+ * 语义化版本比较(只处理 `x.y.z[-pre[.n]]`;不认识 → null = 不可比较)。
40
+ * 预发布版 < 同号正式版(0.1.5-rc.1 < 0.1.5)。
41
+ * @returns {-1|0|1|null}
42
+ */
43
+ export function compareVersions(a, b) {
44
+ const parse = (v) => {
45
+ const m = /^\s*(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v ?? ''));
46
+ if (!m) return null;
47
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ? m[4].split('.') : [] };
48
+ };
49
+ const pa = parse(a);
50
+ const pb = parse(b);
51
+ if (!pa || !pb) return null;
52
+ for (let i = 0; i < 3; i++) if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] < pb.nums[i] ? -1 : 1;
53
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
54
+ if (pa.pre.length === 0) return 1;
55
+ if (pb.pre.length === 0) return -1;
56
+ for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
57
+ const x = pa.pre[i];
58
+ const y = pb.pre[i];
59
+ if (x === undefined) return -1;
60
+ if (y === undefined) return 1;
61
+ const nx = /^\d+$/.test(x);
62
+ const ny = /^\d+$/.test(y);
63
+ if (nx && ny) { if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1; continue; }
64
+ if (nx !== ny) return nx ? -1 : 1;
65
+ if (x !== y) return x < y ? -1 : 1;
66
+ }
67
+ return 0;
68
+ }
69
+
70
+ /** 被检文件格式版本 → 支持档(`baseline` = 与测试基线同版;`legacy` = 旧格式,由本包自带实现支持)。 */
71
+ function fileStatusOf(version) {
72
+ if (!TESTED_BASELINE.knownFormatVersions.includes(version)) return 'unverified';
73
+ return version === TESTED_BASELINE.sessionFormatVersion ? 'baseline' : 'legacy';
74
+ }
75
+
76
+ /**
77
+ * **运行时版本检测**:宿主版本 + 被检文件格式版本,与测试基线对比。
78
+ *
79
+ * @param {{header?:object|null, events?:Array, formatVersion?:number}} [input]
80
+ * @returns {{baseline:object, host:object, file:object, warnings:string[], readOnly:boolean, summary:string}}
81
+ * `readOnly === true` ⇒ 调用方**不得**给出写入结论(prewrite/fix 必须拒绝)——
82
+ * 触发条件:被检文件格式版本未知(不在 knownFormatVersions 内),或格式完全无法识别。
83
+ */
84
+ export function detectSupport({ header, events, formatVersion, hostVersion: hostOverride } = {}) {
85
+ const warnings = [];
86
+ // ── 宿主 ────────────────────────────────────────────────────────────────
87
+ // `hostVersion` 仅用于测试/演示(注入"比基线新的宿主"场景),生产路径不传。
88
+ const hostVersion = hostOverride ?? hostPackageVersion();
89
+ const hostCmp = compareVersions(hostVersion, TESTED_BASELINE.hostVersion);
90
+ let hostStatus;
91
+ if (hostCmp === null) {
92
+ hostStatus = 'unknown';
93
+ warnings.push(`宿主 ${TESTED_BASELINE.hostPackage} 版本不可解析(${hostVersion})——无法与测试基线 ${TESTED_BASELINE.hostVersion} 比对;结论按"未验证"对待。`);
94
+ } else if (hostCmp > 0) {
95
+ hostStatus = 'newer-than-baseline';
96
+ warnings.push(`宿主 ${TESTED_BASELINE.hostPackage}@${hostVersion} **比测试基线 ${TESTED_BASELINE.hostVersion} 新** —— 本规则集未在该宿主上验证,官方语义可能已变(词表/折叠/replace 字段);结论可能不适用,请先核对上游变更或升级本包基线。`);
97
+ } else if (hostCmp < 0) {
98
+ hostStatus = 'older-than-baseline';
99
+ warnings.push(`宿主 ${TESTED_BASELINE.hostPackage}@${hostVersion} 比测试基线 ${TESTED_BASELINE.hostVersion} 旧(仍在声明的 peer 范围 ${TESTED_BASELINE.peerRange} 内)—— v3 文件需要宿主能提供对应词表/折叠(本宿主 SESSION_FORMAT_VERSION=${HOST_MAX_FILE_VERSION})。`);
100
+ } else {
101
+ hostStatus = 'baseline';
102
+ }
103
+
104
+ // ── 被检文件格式 ────────────────────────────────────────────────────────
105
+ const resolved = resolveFormatVersionDetailed({ formatVersion, header, events });
106
+ const unknownSource = resolved.source === 'default';
107
+ // S4(独立核验):无法识别格式时**状态标签必须是 `unverified`**(README 就是这么写的:
108
+ // "Format unrecognisable → same: "unverified format" + read-only")。旧实现让版本回落到默认 0
109
+ // ⇒ 标签打印成 `legacy`,与 README 不一致(`readOnly` 本来就对)。
110
+ const status = unknownSource ? 'unverified' : fileStatusOf(resolved.version);
111
+ const readOnly = unknownSource || status === 'unverified';
112
+ if (unknownSource) {
113
+ warnings.push('被检文件格式版本**无法识别**(无 header.version,且事件形状无判别特征:S4 replace 字段名 / system/message / assistant/chunk / assistant/attempt 皆未见)——按"未验证格式"处理,仅只读。');
114
+ } else if (status === 'unverified') {
115
+ warnings.push(`被检文件格式版本 ${resolved.version} **不在已知集合 {${TESTED_BASELINE.knownFormatVersions.join(',')}}** 内(来源 ${resolved.source})——按"未验证格式"处理,仅只读;不得据此判定可写/可修。`);
116
+ }
117
+ const file = {
118
+ version: resolved.version,
119
+ source: resolved.source, // explicit | header | inferred | default
120
+ status, // baseline | legacy | unverified
121
+ known: status !== 'unverified' && !unknownSource,
122
+ evidence: resolved.evidence ?? null,
123
+ };
124
+ // 无法识别时版本号是占位 0 —— 显示成 `?`(否则"文件格式 v0(default,unverified)"自相矛盾)
125
+ const versionLabel = unknownSource ? '?' : `v${file.version}`;
126
+ const summary = `宿主 ${TESTED_BASELINE.hostPackage}@${hostVersion}(基线 ${TESTED_BASELINE.hostVersion},${hostStatus})`
127
+ + ` | 文件格式 ${versionLabel}(${file.source},${status}${readOnly ? ',只读' : ''})`;
128
+ return {
129
+ baseline: TESTED_BASELINE,
130
+ host: {
131
+ package: TESTED_BASELINE.hostPackage,
132
+ version: hostVersion,
133
+ sessionFormatVersion: HOST_MAX_FILE_VERSION,
134
+ status: hostStatus,
135
+ tested: hostStatus === 'baseline',
136
+ newerThanBaseline: hostStatus === 'newer-than-baseline',
137
+ },
138
+ file,
139
+ warnings,
140
+ readOnly,
141
+ summary,
142
+ };
143
+ }
144
+
145
+ /** 从 `loadSessionLog()` 的结果算支持面(CLI/调用方共用)。 */
146
+ export function supportOfLog(log) {
147
+ return detectSupport({ header: log?.header ?? null, events: (log?.events ?? []).map((e) => e.event ?? e) });
148
+ }
package/lib/vocab.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * dsh-log-contract · lib/vocab.js —— **按被检文件自身版本选词表与折叠路径**(1.3 第二半,engineer comm-369 裁定)
2
+ * dsh-log-contract · lib/vocab.js —— **按被检文件自身版本选词表与折叠路径**(1.3 第二半,内部评审裁定)
3
3
  *
4
4
  * 背景(一手实测):同一健康会话,在**生产 0.1.1 解析**下 0 违规,在 **0.1.5 解析**下 3606 违规——
5
5
  * 根因是本包原先用**运行时导出**的 `KNOWN_SESSION_EVENT_TYPES` / `foldSurface` 去判**旧格式(v0)文件**:
6
6
  * 官方 0.1.5 的词汇表已不含 `assistant/chunk`(v0 词表 51 条里**有**),于是每个 chunk 事件被 E3 误报。
7
- * 裁定原文(comm-369 §二):**"判据必须来自文件自身的版本,不是判官(运行时)的版本"**。
7
+ * 裁定原文(内部评审 §二):**"判据必须来自文件自身的版本,不是判官(运行时)的版本"**。
8
8
  *
9
9
  * 2026-09-14 复核补强(同一裁定的完整落地)——一手证据:
10
10
  * 1. **0.1.5 的 `foldSurface` 不是"放宽",而是换成了 v3 语义**(对照 `dsh-session@0.1.0-rc.7`
@@ -103,6 +103,15 @@ export function isLegacyFormat(version) {
103
103
  * 返回 3 / 2 / 0。**这是启发式**;有 `header.version` 或显式 `formatVersion` 时优先用它们。
104
104
  */
105
105
  export function inferFormatVersion(events = []) {
106
+ return inferFormatVersionDetailed(events) ?? 0;
107
+ }
108
+
109
+ /**
110
+ * 同上,但**没有判别证据时返回 `null`**(而不是假装 0)——供版本支持面报"未验证格式"
111
+ * (2026-09-14 用户要求第 1 条:无法识别格式 ⇒ 报未验证并按只读处理)。
112
+ * @returns {3|2|0|null} 版本号;null = 事件里没有任何判别特征
113
+ */
114
+ export function inferFormatVersionDetailed(events = []) {
106
115
  let sawLegacyReplace = false;
107
116
  let sawModernReplace = false;
108
117
  let sawSystemMessage = false;
@@ -123,7 +132,26 @@ export function inferFormatVersion(events = []) {
123
132
  if (sawLegacyReplace) return sawAttempt ? 2 : 0;
124
133
  if (sawAttempt) return 2;
125
134
  if (sawChunk) return 0;
126
- return 0;
135
+ return null; // 无证据:调用方(版本支持面)据此报"未验证格式"
136
+ }
137
+
138
+ /**
139
+ * 解析版本 + **来源**(版本支持面用):显式 `formatVersion` > `header.version` > 事件形状推断 > 无证据。
140
+ * @param {{formatVersion?:number, header?:object|null, events?:Array}} [input]
141
+ * @returns {{version:number, source:'explicit'|'header'|'inferred'|'default', evidence:string|null}}
142
+ */
143
+ export function resolveFormatVersionDetailed({ formatVersion, header, events } = {}) {
144
+ if (Number.isSafeInteger(formatVersion) && formatVersion >= 0) {
145
+ return { version: formatVersion, source: 'explicit', evidence: null };
146
+ }
147
+ if (header && Number.isSafeInteger(header.version) && header.version >= 0) {
148
+ return { version: header.version, source: 'header', evidence: null };
149
+ }
150
+ const inferred = inferFormatVersionDetailed(events ?? []);
151
+ if (inferred !== null) {
152
+ return { version: inferred, source: 'inferred', evidence: 'replace 字段名/system/message/assistant/{chunk,attempt} 形状' };
153
+ }
154
+ return { version: 0, source: 'default', evidence: null };
127
155
  }
128
156
 
129
157
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-log-contract",
3
3
  "description": "日志契约守护 — DSH session log contract guard: offline health check (CLI) + pre-write validation for DeepSeek Harness session logs",
4
- "version": "0.3.13",
4
+ "version": "0.3.14",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -31,7 +31,7 @@
31
31
  "scripts": {
32
32
  "check": "node scripts/check-syntax.mjs",
33
33
  "test": "vitest run",
34
- "prepublishOnly": "node scripts/check-pkg-meta.mjs && pnpm check && pnpm test"
34
+ "prepublishOnly": "node scripts/check-pkg-meta.mjs && node scripts/check-publish-leaks.mjs && pnpm check && pnpm test"
35
35
  },
36
36
  "keywords": [
37
37
  "dsh",