@furongjun1999/dsh-memory 0.5.1 → 0.6.1

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.
Files changed (187) hide show
  1. package/README.md +588 -552
  2. package/codebuddy/CODEBUDDY.md +10 -10
  3. package/data/policy.json +27 -0
  4. package/docs/discipline/harnesses.yaml +31 -0
  5. package/docs/discipline/templates/full.md.tmpl +1 -1
  6. package/docs/discipline/templates/rules.mdc.tmpl +1 -1
  7. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225v2_/345/217/202/350/200/203dsh-TUI_v1.0.md +248 -0
  8. package/docs/eval/DSH/346/227/245/345/277/227/347/264/242/345/274/225/346/225/210/346/236/234/351/252/214/350/257/201_v1.0.md +209 -0
  9. package/docs/eval/DSH/347/253/257/347/274/272/351/231/267/344/270/223/351/241/271_v1.0.md +254 -0
  10. package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +418 -0
  11. package/docs/eval/P1b2_/350/257/273/351/235/242/344/273/243/351/231/205/344/277/256/345/244/215_v1.0.md +124 -0
  12. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +441 -0
  13. package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +398 -0
  14. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +498 -0
  15. package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +669 -0
  16. package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +683 -0
  17. package/docs/eval//345/217/221/345/270/20306_/344/270/200/351/224/256/351/205/215/347/275/256/344/270/216DSH0172_v1.0.md +171 -0
  18. package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +205 -0
  19. package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
  20. package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
  21. package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +646 -0
  22. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v18.md +183 -0
  23. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v19.md +207 -0
  24. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v20.md +283 -0
  25. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v21.md +224 -0
  26. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v22.md +223 -0
  27. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v23.md +293 -0
  28. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v24.md +303 -0
  29. package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v25.md +230 -0
  30. package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +20 -0
  31. package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
  32. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  33. package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
  34. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  35. package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
  36. package/docs/mdcg//350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +39 -3
  37. package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +220 -0
  38. package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
  39. package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
  40. package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +460 -460
  41. package/dsh/cordis-patch-profile-web.example.yml +35 -0
  42. package/lib/bridge.js +24 -2
  43. package/lib/cli.d.ts +3 -0
  44. package/lib/cli.js +66 -0
  45. package/lib/hooks.d.ts +31 -2
  46. package/lib/hooks.js +219 -16
  47. package/lib/init.d.ts +88 -0
  48. package/lib/init.js +302 -0
  49. package/lib/lib/prompt_safety.d.ts +52 -1
  50. package/lib/lib/prompt_safety.js +76 -1
  51. package/md_cg/audit.py +652 -379
  52. package/md_cg/bench6_arms.py +1 -1
  53. package/md_cg/bench_p0.py +1 -1
  54. package/md_cg/branches.py +2 -2
  55. package/md_cg/ccgc.py +1071 -1006
  56. package/md_cg/chain.py +1 -1
  57. package/md_cg/consistency.py +97 -9
  58. package/md_cg/consolidate.py +34 -9
  59. package/md_cg/crypto.py +59 -26
  60. package/md_cg/docindex.py +16 -1
  61. package/md_cg/evidence.py +585 -582
  62. package/md_cg/evolution.py +17 -1
  63. package/md_cg/export.py +1 -1
  64. package/md_cg/forgetting.py +309 -13
  65. package/md_cg/fsutil.py +454 -8
  66. package/md_cg/identity.py +3 -3
  67. package/md_cg/insight.py +24 -3
  68. package/md_cg/linkref.py +1 -1
  69. package/md_cg/logref.py +327 -0
  70. package/md_cg/mcp_server.py +4152 -3818
  71. package/md_cg/mdcg.py +4208 -3462
  72. package/md_cg/mdcos.py +4491 -4137
  73. package/md_cg/mreview/pipeline.py +15 -1
  74. package/md_cg/nodefile.py +639 -575
  75. package/md_cg/protect.py +158 -11
  76. package/md_cg/protocol.py +41 -2
  77. package/md_cg/provenance.py +22 -4
  78. package/md_cg/reach.py +4 -4
  79. package/md_cg/readcache.py +76 -15
  80. package/md_cg/reconcile.py +1 -1
  81. package/md_cg/refindex.py +246 -37
  82. package/md_cg/refine.py +1 -1
  83. package/md_cg/review_cli.py +49 -4
  84. package/md_cg/routing.py +32 -5
  85. package/md_cg/run_tests.py +17 -0
  86. package/md_cg/scrub.py +30 -3
  87. package/md_cg/security.py +47 -1
  88. package/md_cg/self_state.py +5 -5
  89. package/md_cg/sources.py +23 -6
  90. package/md_cg/srcindex.py +352 -0
  91. package/md_cg/stg.py +47 -3
  92. package/md_cg/subgraph.py +2 -2
  93. package/md_cg/sustain.py +1299 -1168
  94. package/md_cg/tasks.py +469 -470
  95. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  96. package/md_cg/test_b1b2_write_face.py +417 -0
  97. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  98. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  99. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  100. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  101. package/md_cg/test_c3_transient_read_negative.py +793 -0
  102. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  103. package/md_cg/test_ccg_form_parity.py +188 -0
  104. package/md_cg/test_ccgc.py +28 -3
  105. package/md_cg/test_govern_directread.py +17 -4
  106. package/md_cg/test_h2_session_view_norm.py +233 -0
  107. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  108. package/md_cg/test_hive_ingest.py +285 -285
  109. package/md_cg/test_index_crossprocess_reload.py +301 -0
  110. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  111. package/md_cg/test_issue43_default_policy.py +865 -0
  112. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  113. package/md_cg/test_linkref.py +10 -3
  114. package/md_cg/test_lock.py +2 -2
  115. package/md_cg/test_logref.py +1109 -0
  116. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  117. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  118. package/md_cg/test_mr_m2.py +15 -3
  119. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  120. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  121. package/md_cg/test_n176_link_trust.py +221 -0
  122. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  123. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  124. package/md_cg/test_n195_writepath_reload.py +283 -0
  125. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  126. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  127. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  128. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  129. package/md_cg/test_n201_proposal_visibility.py +382 -0
  130. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  131. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  132. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  133. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  134. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  135. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  136. package/md_cg/test_n225_nonobject_load.py +1110 -0
  137. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  138. package/md_cg/test_neg_condition_hits.py +333 -0
  139. package/md_cg/test_neg_tail_honesty.py +663 -0
  140. package/md_cg/test_none_id_write_guard.py +166 -0
  141. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  142. package/md_cg/test_p1.py +5 -5
  143. package/md_cg/test_p11_consistency.py +161 -12
  144. package/md_cg/test_p26_refindex.py +2 -2
  145. package/md_cg/test_p27_docindex.py +777 -774
  146. package/md_cg/test_p28_refcheck.py +740 -13
  147. package/md_cg/test_p29_session_ingest_export.py +2 -2
  148. package/md_cg/test_p2_mcp.py +12 -1
  149. package/md_cg/test_p30_maintain.py +33 -2
  150. package/md_cg/test_p31_insight.py +1 -0
  151. package/md_cg/test_p9c_dedup_hints.py +276 -0
  152. package/md_cg/test_policy_required_ccg.py +426 -0
  153. package/md_cg/test_protocol.py +22 -0
  154. package/md_cg/test_rank_parity_score_mode.py +516 -0
  155. package/md_cg/test_read_face_input_gates.py +363 -0
  156. package/md_cg/test_read_face_semantics.py +489 -0
  157. package/md_cg/test_recall_face_guards.py +812 -0
  158. package/md_cg/test_rejected_credential_forms.py +188 -0
  159. package/md_cg/test_rejected_redact.py +146 -0
  160. package/md_cg/test_retr_s1b.py +2 -2
  161. package/md_cg/test_retr_s5.py +20 -7
  162. package/md_cg/test_review_cli_attribution.py +177 -0
  163. package/md_cg/test_review_cli_visibility.py +235 -0
  164. package/md_cg/test_review_conformance.py +21 -3
  165. package/md_cg/test_security_audit_v21.py +2 -2
  166. package/md_cg/test_server_version.py +67 -0
  167. package/md_cg/test_srcindex.py +171 -0
  168. package/md_cg/test_tenant_env_override_warn.py +63 -50
  169. package/md_cg/test_token_lowercase_form.py +315 -0
  170. package/md_cg/test_v21r1_package_version.py +310 -0
  171. package/md_cg/test_writepipe.py +10 -4
  172. package/md_cg/tokens.py +225 -95
  173. package/md_cg/tool_face.py +4 -4
  174. package/md_cg/trust.py +49 -5
  175. package/md_cg/units.py +204 -6
  176. package/md_cg/weights.py +4 -4
  177. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  178. package/md_cg/writelimit.py +42 -8
  179. package/md_cg/writepipe.py +651 -554
  180. package/package.json +8 -2
  181. package/skills/plugin.json +1 -1
  182. package/src/bridge.ts +25 -2
  183. package/src/cli.ts +65 -0
  184. package/src/hooks.ts +229 -16
  185. package/src/init.ts +361 -0
  186. package/src/lib/prompt_safety.ts +88 -1
  187. package/zcode/AGENTS.md +10 -10
@@ -0,0 +1,1109 @@
1
+ # -*- coding: utf-8 -*-
2
+ """test_logref —— N203 P1/P2 守卫:日志真源 → ref 索引(临时库)。
3
+
4
+ 覆盖 plan §6 守卫表的六条口径(幂等 / 插话两代 / 悬空 / 回读逐字节 / 密级跟源 /
5
+ path 口径)+两面(⓪ 写面边界、⑦ 生产读面端到端),并自带**定点变异红基线**(见下)。
6
+
7
+ 链路:合成 zstd 日志 → `scripts/dsh_log_index.py` 的转写(**该模块一行不改**,只
8
+ importlib 复用)→ `md_cg/logref.write_session` 落 ref 节点 → 两代对账。
9
+
10
+ 守卫面(判据全走**合成夹具**:哑日志/哑正文/临时库,绝不触活库、真实会话与真实转写根)
11
+ ⓪ 写面边界 —— 库根必须在 tempfile 之下,且不得等于转写根/仓检出;**比对走 normcase**
12
+ (Windows realpath 会改大小写,纯 realpath 比对会让「库根=转写根」过闸,评审⑨)。
13
+ ① 幂等 —— 同源重跑 `updated == 0`、全库区间哈希复核全 ok。
14
+ ② 同标题异内容 —— 同区间换正文(**长度不变**)⇒ id 不变而 hash 变:必须被计成
15
+ `updated`;把落库判据改回「id 撞即跳」(变异)后 `updated == 0` 且区间哈希复核
16
+ 出现不一致 ⇒ 该判据必转红。这是 N203 静默的根因面。
17
+ ③ 插话两代 —— 旧式非根章错位槽 ≥ 2(判别力)、新式 `missing/misplaced/roundtrip` 全 0、
18
+ `updated == updated_expected`、旧式重跑全撞 id(旧节点留 v1)。
19
+ ④ 悬空 —— 删真源 ⇒ src 层 dangling;删转写 ⇒ 区间层 dangling 而 **src 层仍 ok**(两层
20
+ 解耦);带 `regenerable` 的节点不被 `prune_dangling` 列入(计数透出),反向对照的
21
+ 非 regenerable 悬空节点**必须**被列入。
22
+ ⑤ 密级跟源 —— 节点密级 == `docindex.sensitivity_for(src["path"], None)` 的**真源路径判**;
23
+ 落进 `crypto.ENCRYPTED_LEVELS` 时报告里必须出现加密告警计数(v1.0 误标 private 致
24
+ 检索零命中,静默复现不可接受);且一律不低于 internal。
25
+ ⑥ path 口径 —— `doc_ref.root` == 转写根、path 为相对路径;同 sid 跨 ws 时**裸文件名
26
+ 口径必撞 id**、改写口径 0 撞;绝对 path 当 rel 在 `probe_ref` 里仍会 status=ok
27
+ (既有实现不拦)⇒ 口径必须由落库侧写死。
28
+
29
+ 红基线(判别力自证,见 `_MUTATIONS`/`EXPECTED_RED`)
30
+ python -X utf8 -m md_cg.test_logref --head-baseline
31
+ 读**工作区** `md_cg/logref.py` 的源码文本,按定点变异表把落库判据(「区间哈希 + 行位
32
+ 全等才算已索引」)改回 N203 v1.0 的「**id 撞即跳**」,用变异后的 `node_state` 跑**同一
33
+ 套** check,断言「红项集合 == 预期红项」(恰好命中,多一项少一项都报红)。锚点命中次数
34
+ 不是恰好 1(漂移/歧义)即 ANCHOR-MISS → fail-closed(**两态都查**:绿态也查,免得锚点
35
+ 漂移后绿线还绿着、红基线已静默失效)。不取 `git show HEAD:` 做基线源——HEAD 在改动
36
+ 提交那一刻就变成实现本身,判别力会静默失效(前车之鉴见
37
+ `md_cg/test_policy_required_ccg.py` 头注:批次 70/77)。
38
+
39
+ 运行:python -X utf8 -m md_cg.test_logref [--head-baseline]
40
+ 依赖:zstandard(缺失 → 正文链路 SKIP 退出 0,对齐 run_tests 裸 clone 不假红口径;
41
+ 但锚点 + 红项表自证照跑——漂移/陈化仍退出 2,不因缺依赖而变纯空转)
42
+ 退出码:绿态 0=全绿;红基线 0=红项集合符合预期;2=锚点漂移/红项表陈化(fail-closed)
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import contextlib
48
+ import importlib.util
49
+ import io
50
+ import json
51
+ import os
52
+ import shutil
53
+ import sys
54
+ import tempfile
55
+ import traceback
56
+
57
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
58
+ sys.path.insert(0, REPO)
59
+
60
+ from md_cg import codeindex, crypto, docindex, logref, refindex, srcindex # noqa: E402
61
+ from md_cg.mdcos import MdCGSecure # noqa: E402
62
+ from md_cg.security import Principal # noqa: E402
63
+
64
+ PASS = FAIL = 0
65
+ FAILS = []
66
+ RED = [] # 转红项 cid(红基线模式比对用;绿态为空)
67
+ TOOL = None
68
+
69
+
70
+ def ck(cid: str, ok: bool, detail="") -> None:
71
+ global PASS, FAIL
72
+ if ok:
73
+ PASS += 1
74
+ else:
75
+ FAIL += 1
76
+ RED.append(cid)
77
+ FAILS.append("%s %s" % (cid, detail))
78
+ print(" %s %s%s" % ("OK " if ok else "FAIL", cid, "" if ok else " " + str(detail)[:220]))
79
+
80
+
81
+ # ---------------- 夹具(全合成:哑 zstd 日志 + 哑正文) ----------------
82
+
83
+ def _body(tag: str, n: int = 16) -> str:
84
+ """合成正文。**必须 ≥ docindex.MIN_BODY=200 字**:不足的章会被并入父章
85
+ (docindex.py:186/:190),夹具就造不出「多节」结构。"""
86
+ return ("【%s】" % tag) + ("合成哑正文片段,仅用于夹具对齐。" * n)
87
+
88
+
89
+ def _msg(turn: int, role: str, text: str, mid: str) -> str:
90
+ if role == "user":
91
+ return json.dumps({"type": "user/message",
92
+ "data": {"id": mid,
93
+ "content": [{"type": "text", "text": text}]}},
94
+ ensure_ascii=False)
95
+ return json.dumps({"type": "assistant/message",
96
+ "data": {"turn": turn, "id": mid,
97
+ "message": {"content": [{"type": "text",
98
+ "text": text}]}}},
99
+ ensure_ascii=False)
100
+
101
+
102
+ def _log_lines(sid: str, mark: int = 1, n_turns: int = 3, splice: bool = False) -> list:
103
+ """合成 v4 日志行:首行 session 元数据 + 每轮 user/assistant(正文各 ≥200 字)。
104
+
105
+ `mark` 换**同长度**的哑正文(用于「同标题同区间换内容」:行数不变、hash 变)。
106
+ `splice=True` 在**首条 user 之后**插入一条 user 消息(靠前处插一节):插入点必须
107
+ 在首条之后,否则 `derive_session_token` 的 basis 变、旧式 id 空间整体换掉。
108
+ """
109
+ ls = [json.dumps({"type": "session", "id": "uuid-" + sid,
110
+ "createdAt": 1750000000000, "cwd": "D:/gt-ws",
111
+ "agentPreset": "p"}, ensure_ascii=False)]
112
+ for t in range(1, n_turns + 1):
113
+ ls.append(json.dumps({"type": "turn/start", "data": {"turn": t}}))
114
+ ls.append(_msg(t, "user", _body("用户%d" % t), "u-%d" % t))
115
+ if splice and t == 1:
116
+ ls.append(_msg(1, "user", _body("插话"), "u-1b"))
117
+ ls.append(_msg(t, "assistant", _body("助手%dr%d" % (t, mark)), "a-%d" % t))
118
+ return ls
119
+
120
+
121
+ def _write_log(path: str, lines: list) -> bytes:
122
+ import zstandard as zstd
123
+ raw = ("\n".join(lines) + "\n").encode("utf-8")
124
+ os.makedirs(os.path.dirname(path), exist_ok=True)
125
+ with open(path, "wb") as f:
126
+ f.write(zstd.ZstdCompressor().compress(raw))
127
+ with open(path, "rb") as f:
128
+ return f.read()
129
+
130
+
131
+ #: 夹具会话(ws-a/private-ws 下同名 sid —— ⑥ 的「同 sid 跨 ws」前提就靠它)
132
+ SESSIONS = (("ws-a", "sess-1"), ("ws-a", "sess-2"), ("private-ws", "sess-1"))
133
+
134
+
135
+ def make_fixture(base: str) -> str:
136
+ sroot = os.path.join(base, "sessions")
137
+ for ws, sid in SESSIONS:
138
+ _write_log(os.path.join(sroot, ws, sid, "session.v4.jsonl.zstd"),
139
+ _log_lines(sid))
140
+ return sroot
141
+
142
+
143
+ def log_path(sroot: str, ws: str, sid: str) -> str:
144
+ return os.path.join(sroot, ws, sid, "session.v4.jsonl.zstd")
145
+
146
+
147
+ # ---------------- 工具驱动(importlib 复用 scripts/_mdcg_reindex_dshlogs.py) ----------------
148
+
149
+ def load_tool():
150
+ p = os.path.join(REPO, "scripts", "_mdcg_reindex_dshlogs.py")
151
+ spec = importlib.util.spec_from_file_location("_n203_guard_tool", p)
152
+ mod = importlib.util.module_from_spec(spec)
153
+ spec.loader.exec_module(mod)
154
+ return mod
155
+
156
+
157
+ def run_tool(argv: list) -> tuple:
158
+ """直调入口 main(argv)(沙箱友好):返回 (rc, 每会话报告, TOTAL, stderr)。"""
159
+ buf_out, buf_err = io.StringIO(), io.StringIO()
160
+ with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
161
+ rc = TOOL.main(list(argv))
162
+ rows, total = {}, None
163
+ for ln in buf_out.getvalue().splitlines():
164
+ ln = ln.strip()
165
+ if not ln.startswith("{"):
166
+ continue
167
+ try:
168
+ o = json.loads(ln)
169
+ except ValueError:
170
+ continue
171
+ if "TOTAL" in o:
172
+ total = o["TOTAL"]
173
+ elif o.get("session"):
174
+ rows["%s/%s" % (o.get("workspace"), o.get("session"))] = o
175
+ return rc, rows, total, buf_err.getvalue()
176
+
177
+
178
+ def base_argv(lib: str, sroot: str, tr: str, mode: str, ws: str = None,
179
+ sid: str = None) -> list:
180
+ a = ["--root", lib, "--sessions-root", sroot, "--transcripts-root", tr,
181
+ "--reconcile", mode]
182
+ if ws:
183
+ a += ["--workspace", ws]
184
+ if sid:
185
+ a += ["--session", sid]
186
+ return a
187
+
188
+
189
+ def paths(tmp: str, name: str) -> tuple:
190
+ d = os.path.join(tmp, name)
191
+ os.makedirs(d, exist_ok=True)
192
+ return os.path.join(d, "lib"), os.path.join(d, "tr")
193
+
194
+
195
+ def tr_path(tr: str, ws: str, sid: str) -> str:
196
+ return os.path.join(tr, ws, sid, sid + ".md")
197
+
198
+
199
+ def open_lib(lib: str, *, can_write: bool = True, can_admin: bool = True,
200
+ session: str = None):
201
+ """读库身份:**actor 必须与写入侧一致**(工具缺省 n203-p1)——私有档的 DEK 按身份
202
+ 封装,换 actor 连密钥都解不开(`cg.get` 恒 None);这与密级/会话无关,是密钥面。"""
203
+ p = Principal(actor=TOOL_ARGS["actor"], clearance="private",
204
+ can_write=can_write, can_admin=can_admin, role="designer",
205
+ session=session)
206
+ return MdCGSecure(lib, principal=p, master_key=TOOL.DEFAULT_MASTER_KEY)
207
+
208
+
209
+ #: 与工具缺省一致的身份/租户(守卫只用它们构造读写两侧实例)
210
+ TOOL_ARGS = {"actor": "n203-p1", "tenant": "default"}
211
+
212
+
213
+ def entries_of(cg, tag: str = "logsrc") -> dict:
214
+ """库中带 tag 的**索引条目**(元数据快照,不经读隔离)——private 档节点在
215
+ 「非本会话」身份下 `cg.get` 读不回(会话绑定),故需要元数据面时走这里。"""
216
+ return {nid: (e or {}) for nid, e in list((cg.index.get("nodes") or {}).items())
217
+ if tag in ((e or {}).get("tags") or [])}
218
+
219
+
220
+ def nodes_of(cg, tag: str = "logsrc") -> dict:
221
+ """库中带 tag 的节点 → {nid: node}(读面:cg.get,密级/加密均按真实读隔离)。"""
222
+ out = {}
223
+ for nid, e in sorted((cg.index.get("nodes") or {}).items()):
224
+ if tag not in ((e or {}).get("tags") or []):
225
+ continue
226
+ g = cg.get(nid)
227
+ if g:
228
+ out[nid] = g
229
+ return out
230
+
231
+
232
+ def _int_or(v, d: int = -1) -> int:
233
+ try:
234
+ return int(v)
235
+ except (TypeError, ValueError):
236
+ return d
237
+
238
+
239
+ def _it_key(it: dict) -> tuple:
240
+ return (it.get("hash"), _int_or(it.get("lineno")), _int_or(it.get("end")))
241
+
242
+
243
+ def ref_keys(cg) -> dict:
244
+ """快照库里每个 logsrc 节点**自己的** doc_ref 判据键:{nid: (hash,lineno,end)}。
245
+
246
+ 用于在落库**之前**取「覆写前态」——落库后再取就变成后态,等式会退化成 0==0。
247
+ """
248
+ out = {}
249
+ for nid, node in nodes_of(cg).items():
250
+ _k, ref = refindex.ref_of(node)
251
+ out[nid] = _it_key(ref or {})
252
+ return out
253
+
254
+
255
+ def expected_updated(cg, items, rel: str, pre: dict = None) -> int:
256
+ """`updated` 的**独立**期望值:同 id 但 (hash,lineno,end) 与**前态**不全等的条数。
257
+
258
+ 口径与工具里对账器的 `updated_expected` 同源(「同 id 不同内容必被计数」),但
259
+ 数据面**不借道 `node_state`**——否则变异态下判据会自己跟自己对偶(`node_state`
260
+ 说 unchanged、计数也是 0,等式恒真)。red-baseline 要求这条等式在变异态**必不成立**。
261
+ `pre` 为 `ref_keys` 的前态快照;不给则取**当前**节点(仅适合后态未写的情形)。
262
+ """
263
+ n = 0
264
+ for it in items or []:
265
+ it2 = dict(it)
266
+ it2["path"] = rel
267
+ nid = docindex.node_id(it2)
268
+ if pre is not None:
269
+ cur = pre.get(nid, _MISSING)
270
+ else:
271
+ _k, r = refindex.ref_of(cg.get(nid))
272
+ cur = _it_key(r or {}) if r else _MISSING
273
+ if cur != _it_key(it2):
274
+ n += 1
275
+ return n
276
+
277
+
278
+ #: 「库里没有这个 id / 没有可读 doc_ref」的哨兵(与任何判据键都不相等)
279
+ _MISSING = object()
280
+
281
+
282
+ def hash_audit(cg, tpath: str) -> list:
283
+ """逐节点复核:节点 doc_ref 的 (hash,lineno,end) 与**当前**转写同区间重算是否一致。
284
+
285
+ 返回不一致清单(空即全 ok)。判据与落库判据同源(docindex.region_hash)。
286
+ """
287
+ if not os.path.isfile(tpath):
288
+ return [{"why": "转写缺失", "path": tpath}]
289
+ with open(tpath, encoding="utf-8") as f:
290
+ lines = f.read().split("\n")
291
+ bad = []
292
+ for nid, node in nodes_of(cg).items():
293
+ _k, ref = refindex.ref_of(node)
294
+ if not isinstance(ref, dict) or not ref:
295
+ bad.append({"id": nid, "why": "无 doc_ref"})
296
+ continue
297
+ lo = int(ref.get("lineno") or 1)
298
+ hi = int(ref.get("end") or lo)
299
+ got = codeindex.region_hash(lines, lo, hi) # 区间哈希的**唯一实现**
300
+ if got != ref.get("hash"):
301
+ bad.append({"id": nid, "node_hash": ref.get("hash"), "now": got})
302
+ return bad
303
+
304
+
305
+ # ---------------- ⓪ 写面边界 ----------------
306
+
307
+ def guard_boundary(tmp: str) -> None:
308
+ lib, tr = paths(tmp, "g0")
309
+ # 活库形态用**相对尾段**造(`<tmp>/AEIS/data/mdcg`)——证明「活库」这道判据本身生效,
310
+ # 而不是被「必须在 tempdir 之下」那句顺手拦下(否则它是一条永不触发的死判据)。
311
+ cases = [("空 root", "", tr),
312
+ ("仓检出", REPO, tr),
313
+ ("tempdir 之外", os.path.dirname(tempfile.gettempdir()), tr),
314
+ ("转写根自身", tr, tr),
315
+ ("转写根的**大小写变体**", tr.upper(), tr),
316
+ ("活库形态尾段", os.path.join(tmp, "AEIS", "data", "mdcg"), tr)]
317
+ for label, r, t in cases:
318
+ try:
319
+ TOOL.guard_root(r, t)
320
+ ck("0a[拒绝 %s]" % label, False, "未拒:%r" % (r,))
321
+ except ValueError:
322
+ ck("0a[拒绝 %s]" % label, True)
323
+ ck("0b[normcase 判据存在] 大小写变体的归一结果相等",
324
+ TOOL._norm(tr) == TOOL._norm(tr.upper()),
325
+ (TOOL._norm(tr), TOOL._norm(tr.upper())))
326
+ ck("0c[放行] tempfile 之下的库根", bool(TOOL.guard_root(lib, tr)))
327
+
328
+
329
+ # ---------------- ① 幂等 + ② 同标题异内容(含变异必红) ----------------
330
+
331
+ #: 「id 撞即跳」的变异判据(**进程内对照**,非发版红基线):只认 id,不认 hash/行位。
332
+ #: 发版红基线是 `--head-baseline`(对工作区 logref.py 源文本做定点变异、须恰好命中
333
+ #: EXPECTED_RED);这里保留一份手写对照,是为了在**绿态**也能就地看到「静默」长什么样
334
+ #: (2c/2d:updated==0 而区间哈希复核不一致)——它不随实现漂移,故不能替代定点变异。
335
+ def _id_only_state(cg, it, kind="doc_ref"):
336
+ nid = refindex.node_id_of(it, kind)
337
+ if nid in ((getattr(cg, "index", {}) or {}).get("nodes") or {}):
338
+ return {"id": nid, "state": "unchanged"}
339
+ return {"id": nid, "state": "new"}
340
+
341
+
342
+ def guard_idem_and_content(tmp: str, sroot: str) -> None:
343
+ lib, tr = paths(tmp, "g1")
344
+ a = base_argv(lib, sroot, tr, "off", "ws-a", "sess-1")
345
+ rc1, _r1, t1, _e1 = run_tool(a)
346
+ ck("1a[首轮] rc=0 且新增条目数 > 0",
347
+ rc1 == 0 and (t1 or {}).get("new_items", 0) > 0, (rc1, t1))
348
+ first_new = (t1 or {}).get("new_items", 0)
349
+ ck("1a'[首轮] 未跳过任何会话(无既有节点 ⇒ 水位判据无从生效)",
350
+ (t1 or {}).get("sessions_skipped") == 0, (t1 or {}).get("sessions_skipped"))
351
+ rc2, rows2, t2, _e2 = run_tool(a)
352
+ ck("1b[同源重跑] 会话级 src 水位未变 ⇒ 跳过 re-parse(增量的替代口径)",
353
+ rc2 == 0 and (t2 or {}).get("sessions_skipped") == 1
354
+ and "src 水位未变" in (((rows2 or {}).get("ws-a/sess-1") or {}).get("skip_reason") or ""),
355
+ (rc2, (t2 or {}).get("sessions_skipped")))
356
+ ck("1c[同源重跑] 新增 == 0、updated == 0(无重写)",
357
+ (t2 or {}).get("new_items") == 0 and (t2 or {}).get("updated") == 0,
358
+ (t2 or {}).get("new_items"))
359
+ tp1 = tr_path(tr, "ws-a", "sess-1")
360
+ rel1 = "ws-a/sess-1/sess-1.md"
361
+ _src = srcindex.session_src_id(log_path(sroot, "ws-a", "sess-1"),
362
+ session_uuid="sess-1", workspace="ws-a")
363
+ cg = open_lib(lib)
364
+ try:
365
+ ck("1d[区间哈希全 ok] 节点 doc_ref 与当前转写逐条一致(跳过的前提真的成立)",
366
+ not hash_audit(cg, tp1))
367
+ # 判据的**正例**(单元级):同一条目在库里必须判 unchanged——上面 1b 走的是
368
+ # 水位跳过,这条直接钉落库判据本身,免得「跳过」把判据的可观测性一起跳掉
369
+ states = [logref.node_state(cg, dict(u["item"], path=rel1))
370
+ for u in srcindex.log_units(tp1, _src, path=rel1)]
371
+ ck("1f[落库判据正例] 未变源上逐条判 unchanged",
372
+ bool(states) and all(s["state"] == "unchanged" for s in states),
373
+ [s["state"] for s in states])
374
+ finally:
375
+ cg.close()
376
+
377
+ # ② 同标题同区间换正文(真源侧换字,长度不变 ⇒ 行数与区间不变、hash 变)
378
+ lp = log_path(sroot, "ws-a", "sess-1")
379
+ _write_log(lp, _log_lines("sess-1", mark=2))
380
+ cg = open_lib(lib)
381
+ try:
382
+ pre3 = ref_keys(cg) # **落库前**的前态(落库后取就退化成 0==0)
383
+ finally:
384
+ cg.close()
385
+ rc3, _r3, t3, _e3 = run_tool(a)
386
+ ck("2a[同标题异内容] 被计成 updated(不静默)",
387
+ rc3 == 0 and (t3 or {}).get("updated", 0) > 0, (rc3, t3))
388
+ # 交叉核验(独立数据面,不经 node_state):期望值 = 覆写前态与当前条目 (hash,lineno,end)
389
+ # 不全等的条数。变异态(id 撞即跳 ⇒ updated==0 而期望数 > 0)下本条必转红。
390
+ cg = open_lib(lib)
391
+ try:
392
+ exp3 = expected_updated(cg, [u["item"] for u in
393
+ srcindex.log_units(tp1, _src, path=rel1)],
394
+ rel1, pre=pre3)
395
+ finally:
396
+ cg.close()
397
+ ck("2b[同标题异内容] updated == 节点 doc_ref 判「需覆写」的条数(交叉核验)",
398
+ exp3 > 0 and (t3 or {}).get("updated") == exp3,
399
+ ((t3 or {}).get("updated"), exp3))
400
+
401
+ # 2c/2d 变异:把落库判据换回「id 撞即跳」——再换一次正文后重跑,必须表现为静默
402
+ _write_log(lp, _log_lines("sess-1", mark=3))
403
+ orig = logref.node_state
404
+ logref.node_state = _id_only_state
405
+ try:
406
+ rc4, _r4, t4, _e4 = run_tool(a)
407
+ finally:
408
+ logref.node_state = orig
409
+ ck("2c[变异必红] id 撞即跳 ⇒ updated == 0(静默放过)",
410
+ rc4 == 0 and (t4 or {}).get("updated") == 0, (rc4, (t4 or {}).get("updated")))
411
+ cg = open_lib(lib)
412
+ try:
413
+ bad = hash_audit(cg, tr_path(tr, "ws-a", "sess-1"))
414
+ ck("2d[变异必红] 区间哈希复核出现不一致(判据可判)", bool(bad),
415
+ bad[:2])
416
+ finally:
417
+ cg.close()
418
+ _write_log(lp, _log_lines("sess-1")) # 还原真源(后续守卫不吃残留)
419
+ ck("1e[夹具自净] 首轮新增数 == 章节数(可复核的基线)", first_new >= 4, first_new)
420
+
421
+
422
+ # ---------------- ③ 插话两代对账 ----------------
423
+
424
+ def guard_two_generations(tmp: str, sroot: str) -> None:
425
+ lib, tr = paths(tmp, "g3")
426
+ rc, rows, total, err = run_tool(base_argv(lib, sroot, tr, "full", "ws-a", "sess-1"))
427
+ row = (rows or {}).get("ws-a/sess-1") or {}
428
+ rec = row.get("reconcile") or {}
429
+ lg = rec.get("legacy") or {}
430
+ ck("3a[两代] rc=0", rc == 0, (rc, err[:200]))
431
+ ck("3b[两代] 新增条目数 > 0", (total or {}).get("new_items", 0) > 0, total)
432
+ ck("3c[两代] 遗漏数 == 0", (total or {}).get("missing") == 0,
433
+ (total or {}).get("missing"))
434
+ ck("3d[两代] 错位数 == 0", (total or {}).get("misplaced") == 0,
435
+ (total or {}).get("misplaced"))
436
+ ck("3e[两代] 回读逐字节 roundtrip == 0", rec.get("roundtrip_mismatch") == 0,
437
+ rec.get("roundtrip_rows"))
438
+ ck("3f[判别力] 旧式非根章错位槽 >= 2",
439
+ lg.get("misplaced_slots", 0) >= 2, lg.get("misplaced_slots"))
440
+ ck("3f'[判别力] 错位槽行的 recorded != now(确为错位而非同章)",
441
+ bool(lg.get("misplaced_rows")) and all(
442
+ list(r.get("recorded") or []) != list(r.get("now") or [])
443
+ for r in (lg.get("misplaced_rows") or [])), lg.get("misplaced_rows")[:1])
444
+ ck("3g[基线自洽] 旧节点记录 == items_v1(legacy_claim == 0)",
445
+ lg.get("claim") == 0, lg.get("claim_rows")[:1])
446
+ ck("3h[交叉核验] updated == updated_expected", rec.get("updated") == rec.get("updated_expected"),
447
+ (rec.get("updated"), rec.get("updated_expected")))
448
+ ck("3i[旧式位置化] 第 1 代全写(旧式无既有节点,skipped=0)",
449
+ lg.get("skipped_existing") == 0 and lg.get("nodes") == row.get("items_v1"),
450
+ (lg.get("skipped_existing"), lg.get("nodes"), row.get("items_v1")))
451
+ ck("3i'[旧式位置化] 第 2 代按 id 撞即跳 ⇒ 跳过数 == 第 1 代条目数(旧节点留 v1)",
452
+ lg.get("gen2_skipped") == row.get("items_v1"),
453
+ (lg.get("gen2_skipped"), row.get("items_v1")))
454
+ ck("3j[旧式位置化] 尾部新增槽 == max(0, n2-n1)",
455
+ lg.get("tail_ok") is True, (lg.get("gen2_indexed"), lg.get("tail_expected")))
456
+ ck("3k[新式认领自己] misplaced_rows 为空", not rec.get("misplaced_rows"),
457
+ rec.get("misplaced_rows")[:1])
458
+
459
+
460
+ # ---------------- ③' 派生标识碰撞(N167 面)下的两代对账 ----------------
461
+
462
+ def guard_token_collision(tmp: str, sroot: str) -> None:
463
+ """夹具里 ws-a/sess-1 与 ws-a/sess-2 的 createdAt 与首条 user 正文逐字相同 ⇒
464
+ `derive_session_token` 派生同一 token(N167 的碰撞面)。此时旧式节点会整体切
465
+ `ext_token` 兜底空间(dsh_log_index.py:596-607)——对账必须按**生效 token** 取旧节点。
466
+ """
467
+ lib, tr = paths(tmp, "g3c")
468
+ rc, rows, total, _err = run_tool(base_argv(lib, sroot, tr, "full", "ws-a"))
469
+ r1 = (rows or {}).get("ws-a/sess-1") or {}
470
+ r2 = (rows or {}).get("ws-a/sess-2") or {}
471
+ lv2 = r2.get("legacy_v1") or {}
472
+ ck("3l[同 token] 两会话派生同一 token(夹具即碰撞面)",
473
+ bool(r1.get("session_token")) and r1.get("session_token") == r2.get("session_token"),
474
+ (r1.get("session_token"), r2.get("session_token")))
475
+ ck("3m[同 token] 第二会话旧式写入切兜底空间(collision=True)",
476
+ lv2.get("collision") is True, lv2)
477
+ ck("3n[同 token] 对账按生效 token ⇒ 基线自洽 + 缺/错位全 0",
478
+ rc == 0 and (r2.get("counts") or {}).get("legacy_claim") == 0
479
+ and (r2.get("counts") or {}).get("missing") == 0
480
+ and (r2.get("counts") or {}).get("misplaced") == 0,
481
+ (rc, r2.get("counts")))
482
+
483
+
484
+ # ---------------- ④ 悬空(两层解耦 + regenerable 免清退) ----------------
485
+
486
+ def guard_dangling(tmp: str, sroot: str) -> None:
487
+ lib, tr = paths(tmp, "g4")
488
+ rc, _rows, _t, _e = run_tool(base_argv(lib, sroot, tr, "off", "ws-a", "sess-1"))
489
+ ck("4a[前置] 落库 rc=0", rc == 0, rc)
490
+ lp = log_path(sroot, "ws-a", "sess-1")
491
+ tp = tr_path(tr, "ws-a", "sess-1")
492
+ with open(lp, "rb") as f:
493
+ log_bytes = f.read()
494
+ cg = open_lib(lib)
495
+ try:
496
+ ns = nodes_of(cg)
497
+ ck("4b[前置] 有 logsrc 节点", bool(ns), len(ns))
498
+ nid, node = sorted(ns.items())[0]
499
+ _k, ref = refindex.ref_of(node)
500
+ with open(tp, "rb") as f:
501
+ t_bytes = f.read()
502
+ # fail-closed 三例(**必须在改动真源之前**:本守卫会删/恢复真源,mtime 一变
503
+ # 「同 stat」就不成立,那时再测是测错东西)。src 用现采身份,避免 mtime 漂移。
504
+ fresh = {"path": ref.get("path"), "root": ref.get("root"),
505
+ "lineno": ref.get("lineno"), "end": ref.get("end"),
506
+ "hash": ref.get("hash"),
507
+ "src": srcindex.session_src_id(lp, session_uuid="sess-1",
508
+ workspace="ws-a")}
509
+ p_stat = logref.probe_src(fresh, verify="stat")
510
+ ck("4b'[stat 快路径 fail-closed] verify=stat 同 stat ⇒ unverified 且 ok=False",
511
+ p_stat.get("status") == "unverified" and p_stat.get("ok") is False
512
+ and p_stat.get("hash_verified") is False, p_stat)
513
+ p_noh = dict(fresh)
514
+ p_noh["src"] = {k: v for k, v in fresh["src"].items() if k != "file_hash"}
515
+ ck("4b''[缺 file_hash] fail-closed ⇒ unverified(不假装核过)",
516
+ logref.probe_src(p_noh).get("status") == "unverified",
517
+ logref.probe_src(p_noh).get("status"))
518
+ p_nos = {"path": ref.get("path"), "root": ref.get("root")}
519
+ ck("4b'''[无 src 子键] ⇒ unresolved(不抛 KeyError)",
520
+ logref.probe_src(p_nos).get("status") == "unresolved",
521
+ logref.probe_src(p_nos).get("status"))
522
+ try:
523
+ os.remove(lp) # 删真源
524
+ ck("4c[删真源] src 层 dangling",
525
+ logref.probe_src(ref).get("status") == "dangling",
526
+ logref.probe_src(ref))
527
+ finally:
528
+ with open(lp, "wb") as f:
529
+ f.write(log_bytes)
530
+ ck("4d[真源恢复] src 层 ok", logref.probe_src(ref).get("status") == "ok")
531
+ try:
532
+ with open(lp, "ab") as f:
533
+ f.write(b"x") # 追加一字节:内容变、大小变
534
+ ck("4c'[改真源] src 层 stale(hash 已变)",
535
+ logref.probe_src(ref).get("status") == "stale",
536
+ logref.probe_src(ref).get("status"))
537
+ finally:
538
+ with open(lp, "wb") as f:
539
+ f.write(log_bytes)
540
+ ck("4d'''[真源再恢复] src 层回 ok", logref.probe_src(ref).get("status") == "ok")
541
+ try:
542
+ os.remove(tp) # 删转写
543
+ rd = logref.read_index_node(node, with_src=True)
544
+ ck("4e[删转写] 区间层 dangling 而 src 层仍 ok(两层解耦)",
545
+ rd.get("span_status") == "dangling"
546
+ and (rd.get("src") or {}).get("status") == "ok",
547
+ (rd.get("span_status"), (rd.get("src") or {}).get("status")))
548
+ ck("4f[删转写] 指路离线再生(服务态不写临时目录)",
549
+ "repair" in (rd.get("rematerialize") or ""), rd.get("rematerialize"))
550
+ # regenerable 免清退:dry_run 里不出现,且实做后节点仍在
551
+ pl = refindex.prune_dangling(cg, dry_run=True, max_nodes=5000)
552
+ ck("4g[regenerable 免清退] dry_run 候选为空且计数透出",
553
+ pl.get("candidates") == 0 and pl.get("regenerable_skipped", 0) >= len(ns),
554
+ (pl.get("candidates"), pl.get("regenerable_skipped"), len(ns)))
555
+ refindex.prune_dangling(cg, max_nodes=5000)
556
+ ck("4h[regenerable 免清退] 实做后节点仍在库",
557
+ cg.get(nid) is not None and cg.get(nid) is not None)
558
+ # 反向对照:非 regenerable 的悬空 doc 节点**必须**被列入
559
+ fake = {"path": "nope/missing.md", "heading": "合成悬空章",
560
+ "heading_path": ["合成悬空章"], "level": 1, "lineno": 1,
561
+ "end": 3, "anchor": "synth", "hash": "deadbeef", "lang": "md",
562
+ "precise": True, "parent": "", "children": [], "summary_parts": "哑"}
563
+ refindex.add_items(cg, [fake], kind="doc_ref",
564
+ root=os.path.join(tmp, "g4", "nope"))
565
+ fid = docindex.node_id(fake)
566
+ pl2 = refindex.prune_dangling(cg, dry_run=True, max_nodes=5000)
567
+ ck("4i[反向对照] 非 regenerable 的悬空节点被列入(判据有判别力)",
568
+ fid in (pl2.get("pruned") or []) or pl2.get("candidates", 0) >= 1,
569
+ (pl2.get("candidates"), (pl2.get("pruned") or [])[:3]))
570
+ finally:
571
+ with open(tp, "wb") as f:
572
+ f.write(t_bytes)
573
+ ck("4j[转写恢复] 区间层回 ok",
574
+ logref.read_index_node(cg.get(nid)).get("span_status") == "ok")
575
+ finally:
576
+ cg.close()
577
+
578
+
579
+ # ---------------- ④'' heal 重建后「可再生」仍生效(判据不依赖标签存活) ----------------
580
+
581
+ def guard_regenerable_after_rebuild(tmp: str, sroot: str) -> None:
582
+ """`refindex.rebuild`(sustain.heal 的修复动作)只重放 layer 与 src,**不重放**
583
+ extra 标签 ⇒ `regenerable` 会消失。此时「可再生免清退」必须仍成立——第二判据是
584
+ `doc_ref.src.file_hash`(真源仍在)。只认标签 = 这条保护在重建后静默失效。
585
+ """
586
+ lib, tr = paths(tmp, "g4s")
587
+ rc, _rows, _t, _e = run_tool(base_argv(lib, sroot, tr, "off", "ws-a", "sess-1"))
588
+ ck("4o[前置] 落库 rc=0", rc == 0)
589
+ cg = open_lib(lib)
590
+ try:
591
+ before = entries_of(cg)
592
+ out = refindex.rebuild(cg, only_roots=[tr])
593
+ after = entries_of(cg)
594
+ ck("4p[heal] rebuild 重建完成且报告按序(indexed>0)",
595
+ out.get("ok") is True and out.get("indexed", 0) > 0, out.get("errors")[:1])
596
+ ck("4q[heal] rebuild 保留了 doc_ref.src(真源身份层不丢)",
597
+ all((refindex.ref_of(cg.get(n))[1] or {}).get("src")
598
+ for n in after), sorted(after)[:2])
599
+ ck("4r[heal] 前提成立:附加标签确实被 rebuild 丢掉(logsrc 消失)",
600
+ bool(before) and all("logsrc" not in (e.get("tags") or [])
601
+ for e in after.values()), sorted(after))
602
+ # 删转写 → 全部 dangling:但「可再生」必须仍生效(第二判据靠 doc_ref.src)
603
+ os.remove(tr_path(tr, "ws-a", "sess-1"))
604
+ pl = refindex.prune_dangling(cg, dry_run=True, max_nodes=5000)
605
+ ck("4s[heal 后] 无 regenerable 标签仍免清退(判据不依赖标签存活)",
606
+ pl.get("candidates") == 0 and pl.get("regenerable_skipped", 0) >= len(after),
607
+ (pl.get("candidates"), pl.get("regenerable_skipped"), len(after)))
608
+ finally:
609
+ cg.close()
610
+
611
+
612
+ # ---------------- ④' 再生(--repair 1 的离线再生面) ----------------
613
+
614
+ def guard_repair(tmp: str, sroot: str) -> None:
615
+ """转写被清理(系统清 %TEMP%)后:服务态只报 dangling 指路,离线 `--repair 1` 再生。"""
616
+ lib, tr = paths(tmp, "g4r")
617
+ rc, _rows, _t, _e = run_tool(base_argv(lib, sroot, tr, "off", "ws-a", "sess-1"))
618
+ tp = tr_path(tr, "ws-a", "sess-1")
619
+ ck("4k[前置] 落库 rc=0 且转写在位", rc == 0 and os.path.isfile(tp))
620
+ os.remove(tp) # 模拟系统清理转写
621
+ rc2, _r2, t2, _e2 = run_tool(base_argv(lib, sroot, tr, "off", "ws-a", "sess-1")
622
+ + ["--repair", "1"])
623
+ rep = (t2 or {}).get("repair") or {}
624
+ ck("4l[--repair 1] 前置 dangling > 0 且再生后归零",
625
+ rc2 == 0 and rep.get("pre_dangling", 0) > 0 and rep.get("post_dangling") == 0,
626
+ (rc2, rep))
627
+ ck("4m[--repair 1] 转写被重生成且层位一致",
628
+ os.path.isfile(tp) and (t2 or {}).get("missing", 0) == 0)
629
+ cg = open_lib(lib)
630
+ try:
631
+ ns = nodes_of(cg)
632
+ nid = sorted(ns)[0]
633
+ rd = logref.read_index_node(cg.get(nid), with_src=True)
634
+ ck("4n[--repair 1] 区间层回读 ok 且哈希一致",
635
+ rd.get("span_status") == "ok" and rd.get("hash_match") is True,
636
+ (rd.get("span_status"), rd.get("hash_match")))
637
+ finally:
638
+ cg.close()
639
+
640
+
641
+ # ---------------- ⑤ 密级跟源 ----------------
642
+
643
+ def guard_sensitivity(tmp: str, sroot: str) -> None:
644
+ lib, tr = paths(tmp, "g5a")
645
+ rc, _rows, t, _e = run_tool(base_argv(lib, sroot, tr, "off", "ws-a", "sess-1"))
646
+ cg = open_lib(lib)
647
+ try:
648
+ bad = []
649
+ for nid, node in nodes_of(cg).items():
650
+ _k, ref = refindex.ref_of(node)
651
+ src = (ref or {}).get("src") or {}
652
+ want = docindex.sensitivity_for(src.get("path") or "", None)[0]
653
+ got = (node.get("frontmatter") or {}).get("sensitivity")
654
+ if got != want:
655
+ bad.append({"id": nid, "got": got, "want": want})
656
+ ck("5a[密级跟源] 节点密级 == 真源路径判", not bad, bad[:2])
657
+ _first = next(iter(nodes_of(cg).values()), None)
658
+ _src_path = ((refindex.ref_of(_first)[1] or {}) if _first else {}).get(
659
+ "src", {}).get("path") or ""
660
+ ck("5a'[密级跟源] 真源路径判结果为内部档(夹具 ws-a 路径无私有提示)",
661
+ docindex.sensitivity_for(_src_path, None)[0] == "internal", _src_path)
662
+ ck("5b[不低于 internal] 无节点落 public 及更低档",
663
+ all((n.get("frontmatter") or {}).get("sensitivity") in
664
+ ("internal", "private", "secret") for n in nodes_of(cg).values()))
665
+ finally:
666
+ cg.close()
667
+ ck("5c[internal 档] 无加密告警(加密计数 == 0)", (t or {}).get("encrypted_nodes") == 0,
668
+ (t or {}).get("encrypted_nodes"))
669
+
670
+ # 私有提示路径(private-ws)⇒ 真源路径判为 private ⇒ 加密档 + 告警计数必现
671
+ lib2, tr2 = paths(tmp, "g5b")
672
+ rc2, _r2, t2, err2 = run_tool(base_argv(lib2, sroot, tr2, "off", "private-ws", "sess-1"))
673
+ cg2 = open_lib(lib2)
674
+ try:
675
+ ent = entries_of(cg2)
676
+ sens = {(e.get("sensitivity")) for e in ent.values()}
677
+ ck("5d[密级跟源] 私有提示路径 ⇒ 节点落 private", sens == {"private"}, sens)
678
+ ck("5e[加密档] private ∈ crypto.ENCRYPTED_LEVELS",
679
+ "private" in tuple(crypto.ENCRYPTED_LEVELS))
680
+ ck("5f[加密告警计数必现] 报告 encrypted_nodes > 0",
681
+ (t2 or {}).get("encrypted_nodes", 0) > 0, (t2 or {}).get("encrypted_nodes"))
682
+ ck("5g[加密告警计数必现] stderr 有 WARNING(不静默)",
683
+ "WARNING" in (err2 or "") and "加密" in (err2 or ""), (err2 or "")[:200])
684
+ finally:
685
+ cg2.close()
686
+ # 读面复核:private 档按 frontmatter.session 绑定(mdcos.py:4048-4056),**can_admin
687
+ # 豁免**(:4049-4050)——故此处必须用非 admin 身份,否则「绑定」这条看不出差别。
688
+ tok = ((_r2 or {}).get("private-ws/sess-1") or {}).get("session_token")
689
+ same = open_lib(lib2, can_write=False, can_admin=False, session=tok)
690
+ try:
691
+ n_same = nodes_of(same)
692
+ ck("5h[会话绑定] 归属会话身份可回读且密级为 private",
693
+ bool(n_same) and all((n.get("frontmatter") or {}).get("sensitivity")
694
+ == "private" for n in n_same.values()),
695
+ len(n_same))
696
+ finally:
697
+ same.close()
698
+ for label, sess in (("异会话身份", "sess_other"), ("无会话身份", None)):
699
+ other = open_lib(lib2, can_write=False, can_admin=False, session=sess)
700
+ try:
701
+ ck("5i[会话绑定] %s读不回 private 档节点(可见性单点在 sensitivity)" % label,
702
+ not nodes_of(other), len(nodes_of(other)))
703
+ finally:
704
+ other.close()
705
+
706
+
707
+ # ---------------- ⑥ path 口径 ----------------
708
+
709
+ def guard_path_convention(tmp: str, sroot: str) -> None:
710
+ lib, tr = paths(tmp, "g6")
711
+ rc, _rows, _t, _e = run_tool(base_argv(lib, sroot, tr, "off", "ws-a", "sess-1"))
712
+ tp = tr_path(tr, "ws-a", "sess-1")
713
+ cg = open_lib(lib)
714
+ try:
715
+ ns = nodes_of(cg)
716
+ bad = []
717
+ for nid, node in ns.items():
718
+ _k, ref = refindex.ref_of(node)
719
+ ref = ref or {}
720
+ if os.path.normcase(os.path.realpath(ref.get("root") or "")) != \
721
+ os.path.normcase(os.path.realpath(tr)):
722
+ bad.append({"id": nid, "root": ref.get("root")})
723
+ elif os.path.isabs(ref.get("path") or "") or \
724
+ (ref.get("path") or "").startswith(("/", "\\")):
725
+ bad.append({"id": nid, "path": ref.get("path")})
726
+ ck("6a[口径] doc_ref.root == 转写根 且 path 为相对路径", not bad, bad[:2])
727
+ ck("6b[口径] path 逐字为 <ws>/<sid>/<sid>.md",
728
+ bool(ns) and all((refindex.ref_of(n)[1] or {}).get("path")
729
+ == "ws-a/sess-1/sess-1.md" for n in ns.values()))
730
+ _k, ref0 = refindex.ref_of(next(iter(ns.values())))
731
+ # 绝对 path 当 rel:既有 probe_ref 的 os.path.join 会丢前缀 ⇒ 仍 status=ok(不拦)
732
+ abs_ref = dict(ref0 or {})
733
+ abs_ref["path"] = tp
734
+ ck("6c[陷阱存在] 绝对 path 当 rel 时 probe_ref 仍 status=ok(既有实现不拦)",
735
+ refindex.probe_ref(abs_ref).get("status") == "ok",
736
+ refindex.probe_ref(abs_ref).get("status"))
737
+ finally:
738
+ cg.close()
739
+
740
+ # 撞 id 前提 = **同 sid 跨 ws**:裸文件名口径必撞,改写口径 0 撞
741
+ text = open(tp, encoding="utf-8").read()
742
+ def _ids(rel_of):
743
+ out = []
744
+ for ws, sid in (("ws-a", "sess-1"), ("private-ws", "sess-1")):
745
+ out += [docindex.node_id(it) for it in
746
+ docindex.extract(text, path=rel_of(ws, sid))]
747
+ return out
748
+ bare = _ids(lambda ws, sid: "%s.md" % sid) # 裸文件名口径
749
+ rel = _ids(lambda ws, sid: "%s/%s/%s.md" % (ws, sid, sid)) # 本工具的口径
750
+ # 降级档:--src-path hash ⇒ path 置空 ⇒ probe_src 必须如实 unresolved(不猜、不假装能核)
751
+ lib_h, tr_h = paths(tmp, "g6h")
752
+ rc_h, _r_h, _t_h, _e_h = run_tool(base_argv(lib_h, sroot, tr_h, "off", "ws-a", "sess-1")
753
+ + ["--src-path", "hash"])
754
+ cg_h = open_lib(lib_h)
755
+ try:
756
+ n_h = nodes_of(cg_h)
757
+ _k, ref_h = refindex.ref_of(next(iter(n_h.values())))
758
+ pr = logref.probe_src(ref_h)
759
+ ck("6f[降级档 hash] src.path 置空且 probe_src 如实 unresolved",
760
+ rc_h == 0 and (ref_h or {}).get("src", {}).get("path") == ""
761
+ and pr.get("status") == "unresolved" and pr.get("ok") is False,
762
+ (rc_h, (ref_h or {}).get("src", {}).get("path"), pr.get("status")))
763
+ ck("6g[降级档 hash] 候选路径仍带 file_hash(身份层不因降级而丢判据)",
764
+ bool(((ref_h or {}).get("src") or {}).get("file_hash")))
765
+ finally:
766
+ cg_h.close()
767
+
768
+ ck("6d[撞 id 前提] 裸文件名口径**同 sid 跨 ws** 撞 id",
769
+ len(bare) - len(set(bare)) > 0, (len(bare), len(set(bare))))
770
+ ck("6e[改写口径] <ws>/<sid>/<sid>.md 零撞 id",
771
+ len(rel) - len(set(rel)) == 0, (len(rel), len(set(rel))))
772
+
773
+
774
+ # ---------------- ⑦ P2 端到端:检索命中 → 区间回读真源 ----------------
775
+
776
+ def _read_lines(p: str) -> list:
777
+ with open(p, encoding="utf-8") as f:
778
+ return f.read().split("\n")
779
+
780
+
781
+ def _write_text(p: str, text: str) -> None:
782
+ os.makedirs(os.path.dirname(p), exist_ok=True)
783
+ with open(p, "w", encoding="utf-8", newline="\n") as f:
784
+ f.write(text)
785
+
786
+
787
+ def _roundtrip(cg, tp: str, query: str, k: int = 10) -> tuple:
788
+ """检索命中 → `refindex.read_ref`(**唯一实现**)→ 逐字节比对源同区间切片。"""
789
+ lines = _read_lines(tp)
790
+ res, _meta = cg.search(query, k=k)
791
+ detail, mism = [], []
792
+ for n, _s, _q in res:
793
+ _kd, ref = refindex.ref_of(n)
794
+ if not isinstance(ref, dict) or not ref:
795
+ mism.append({"id": n.get("id"), "why": "命中项无 doc_ref"})
796
+ continue
797
+ got = refindex.read_ref(ref)
798
+ lo = max(0, int(ref.get("lineno") or 1) - 1)
799
+ hi = max(lo, int(ref.get("end") or lo))
800
+ want = "\n".join(lines[lo:hi])
801
+ ok = (bool(got.get("ok")) and got.get("text") == want
802
+ and got.get("hash_match") is True)
803
+ detail.append((n.get("id"), ref.get("lineno"), ref.get("end"),
804
+ len(want), ok))
805
+ if not ok:
806
+ mism.append({"id": n.get("id"), "ok": got.get("ok"),
807
+ "hash_match": got.get("hash_match")})
808
+ return res, detail, mism
809
+
810
+
811
+ def guard_p2_search_readback(tmp: str, sroot: str) -> None:
812
+ """P2 三条证明(全部走真实检索入口与 read_ref 单点):
813
+ ①端到端:问句 → cg.search 命中索引节点 → 命中的 doc_ref → read_ref 取回区间;
814
+ ②逐字节:read_ref 文本 == **源**(转写)同区间切片,且 hash_match=True;
815
+ ③幂等:源不变重跑 ⇒ 新增条目数 0;
816
+ ④无静默:源在靠前处插入一节 ⇒ 旧条目仍可读(text 取回)但按哈希判为 stale(计数
817
+ 非零);重跑后新条目出现、报告计数非零、旧 id 仍可读。
818
+ """
819
+ lib, tr = paths(tmp, "g7")
820
+ ws, sid = "ws-a", "sess-1"
821
+ tp = tr_path(tr, ws, sid)
822
+ lp = log_path(sroot, ws, sid)
823
+ dlg = TOOL.load_dsh_log_index()
824
+ a = base_argv(lib, sroot, tr, "off", ws, sid)
825
+ rc, _rows, total, _e = run_tool(a)
826
+ ck("7a[前置] 落库 rc=0 且新增条目数 > 0",
827
+ rc == 0 and (total or {}).get("new_items", 0) > 0, (rc, total))
828
+
829
+ cg = open_lib(lib, can_write=False)
830
+ try:
831
+ res, detail, mism = _roundtrip(cg, tp, "合成哑正文片段,仅用于夹具对齐")
832
+ ck("7b[检索命中] 命中的日志索引节点全部带 doc_ref(命中即指得回)",
833
+ bool(res) and all(refindex.ref_of(n)[1] for n, _s, _q in res),
834
+ len(res))
835
+ ck("7c[逐字节] read_ref 文本 == 源同区间切片(逐条)", not mism, mism[:1])
836
+ print(" [P2① 检索命中 %d 条 → 区间回读] " % len(detail)
837
+ + ";".join("%s L%s-%s %d 字符 逐字节相等=%s" % d for d in detail[:4]))
838
+ old_ids = [n.get("id") for n, _s, _q in res]
839
+ finally:
840
+ cg.close()
841
+
842
+ # ③ 幂等:源不变重跑
843
+ rc2, _rows2, t2, _e2 = run_tool(a)
844
+ cg = open_lib(lib, can_write=False)
845
+ try:
846
+ _res3, _d3, mism3 = _roundtrip(cg, tp, "合成哑正文片段,仅用于夹具对齐")
847
+ ck("7d[幂等] 源不变重跑 ⇒ 新增条目数 == 0",
848
+ rc2 == 0 and (t2 or {}).get("new_items") == 0,
849
+ (rc2, (t2 or {}).get("new_items"), (t2 or {}).get("sessions_skipped")))
850
+ ck("7e[幂等] 重跑后回读仍逐字节相等(区间未漂)", not mism3, mism3[:1])
851
+ finally:
852
+ cg.close()
853
+
854
+ # ④ 无静默:源在靠前处插入一节(真源日志插入一条消息 + 真代码重渲染转写)
855
+ parsed_before = dlg.parse_session_log(lp)
856
+ tok_before = dlg.derive_session_token(parsed_before["meta"], parsed_before["msgs"])
857
+ _write_log(lp, _log_lines(sid, splice=True))
858
+ parsed2 = dlg.parse_session_log(lp)
859
+ tok_after = dlg.derive_session_token(parsed2["meta"], parsed2["msgs"])
860
+ _write_text(tp, dlg.render_transcript(ws, parsed2)) # 源已变、索引尚未重跑
861
+ ck("7f[无静默/插入] 插入点在首条 user 之后 ⇒ 派生标识不变(同 id 空间)",
862
+ bool(tok_before) and tok_before == tok_after, (tok_before, tok_after))
863
+ cg = open_lib(lib, can_write=False)
864
+ try:
865
+ stale = readable = 0
866
+ for nid in old_ids:
867
+ node = cg.get(nid)
868
+ _kd, ref = refindex.ref_of(node)
869
+ got = refindex.read_ref(ref)
870
+ if got.get("ok"):
871
+ readable += 1
872
+ if got.get("hash_match") is False:
873
+ stale += 1
874
+ ck("7g[无静默/stale] 旧条目仍**可读**(text 取回)且按哈希判为 stale,计数非零",
875
+ stale > 0 and readable > 0, (stale, readable, len(old_ids)))
876
+ print(" [P2④ 插入后·重跑前] 旧节点 %d 条:可读 %d · 判 stale %d(计数非零=%s)"
877
+ % (len(old_ids), readable, stale, stale > 0))
878
+ finally:
879
+ cg.close()
880
+ rc3, rows3, t3, _e3 = run_tool(a)
881
+ row3 = (rows3 or {}).get("%s/%s" % (ws, sid)) or {}
882
+ ck("7h[无静默/新条目] 重跑后新条目出现(恰好插入的那一节 ⇒ 新增 == 1)",
883
+ rc3 == 0 and (t3 or {}).get("new_items") == 1, (rc3, (t3 or {}).get("new_items")))
884
+ ck("7i[无静默/计数非零] 报告同时透出覆写数(旧条目被计数,不静默)",
885
+ (t3 or {}).get("updated", 0) > 0 and bool(row3.get("gen1")), row3.get("gen1"))
886
+ cg = open_lib(lib, can_write=False)
887
+ try:
888
+ alive = 0
889
+ for nid in old_ids:
890
+ node = cg.get(nid)
891
+ _kd, ref = refindex.ref_of(node)
892
+ got = refindex.read_ref(ref)
893
+ if got.get("ok") and got.get("hash_match") is True:
894
+ alive += 1
895
+ ck("7j[无静默/重跑后] 旧 id 全部仍可读且哈希一致", alive == len(old_ids),
896
+ (alive, len(old_ids)))
897
+ _res4, detail4, mism4 = _roundtrip(cg, tp, "合成哑正文片段,仅用于夹具对齐")
898
+ ck("7k[重跑后] 命中项回读仍逐字节相等(含插入节的新条目)",
899
+ not mism4 and len(detail4) > len(old_ids) - 1, (len(detail4), mism4[:1]))
900
+ print(" [P2② 重跑后·检索命中 %d 条 → 逐字节回读] 全部相等=%s"
901
+ % (len(detail4), not mism4))
902
+
903
+ # ---- 生产读面(mcp_server op=ref action=read):同一单点 read_ref + 两层状态 ----
904
+ from md_cg.mcp_server import call_tool
905
+ nid0 = (_res4[0][0].get("id") if _res4 else None)
906
+ _kd, ref0 = refindex.ref_of(cg.get(nid0))
907
+ lines4 = _read_lines(tp)
908
+ want0 = "\n".join(lines4[max(0, int(ref0["lineno"]) - 1):max(0, int(ref0["end"]))])
909
+ out = call_tool(cg, "cg", {"op": "ref", "action": "read", "node_id": nid0})
910
+ ck("7l[生产读面] op=ref read 走同一 read_ref:文本逐字节 == 源切片",
911
+ out.get("ok") is True and out.get("hash_match") is True
912
+ and out.get("text") == want0, (out.get("ok"), out.get("hash_match")))
913
+ out2 = call_tool(cg, "cg", {"op": "ref", "action": "read", "node_id": nid0,
914
+ "with_src": True})
915
+ ck("7m[生产读面] 日志节点带两层状态;with_src=True 才核真源(src.status=ok)",
916
+ out2.get("span_status") == "ok" and out2.get("src_verified") is True
917
+ and (out2.get("src") or {}).get("status") == "ok",
918
+ (out2.get("span_status"), out2.get("src_verified")))
919
+ out_default = call_tool(cg, "cg", {"op": "ref", "action": "read", "node_id": nid0})
920
+ ck("7m'[生产读面] with_src 缺省不核真源且显式标注(src_verified=False)",
921
+ out_default.get("src_verified") is False
922
+ and "未看真源" in (out_default.get("src_note") or ""),
923
+ (out_default.get("src_verified"), out_default.get("src_note")))
924
+ os.remove(tp)
925
+ out3 = call_tool(cg, "cg", {"op": "ref", "action": "read", "node_id": nid0,
926
+ "with_src": True})
927
+ ck("7n[生产读面] 转写被清理:区间层 dangling 而真源层仍 ok,且指路离线再生",
928
+ out3.get("span_status") == "dangling"
929
+ and (out3.get("src") or {}).get("status") == "ok"
930
+ and "repair" in (out3.get("rematerialize") or ""),
931
+ (out3.get("span_status"), (out3.get("src") or {}).get("status"),
932
+ out3.get("rematerialize")))
933
+ _write_text(tp, "\n".join(lines4))
934
+ finally:
935
+ cg.close()
936
+
937
+
938
+ # ---------------- 红基线:定点变异(锚点漂移 fail-closed) ----------------
939
+
940
+ #: 定点变异表:(标签, 现实现锚点, 改回后的旧行为)。
941
+ #: **源 = 工作区 md_cg/logref.py 的文本**(不取 git HEAD:HEAD 在改动提交那一刻就等于
942
+ #: 实现本身,判别力静默失效——同款教训见 md_cg/test_policy_required_ccg.py 头注)。
943
+ #: 锚点必须**恰好命中 1 次**:0 次=漂移、≥2 次=歧义,皆 ANCHOR-MISS → fail-closed。
944
+ _MUTATIONS = (
945
+ ("落库判据:区间哈希 + 行位全等",
946
+ ' same = (ref.get("hash") == it.get("hash")\n'
947
+ ' and _int(ref.get("lineno")) == _int(it.get("lineno"))\n'
948
+ ' and _int(ref.get("end")) == _int(it.get("end")))',
949
+ " same = True"),
950
+ )
951
+
952
+ #: 变异态下**必须**转红的项:**恰好**命中这个集合(多一项少一项都报红——多一项说明
953
+ #: 该断言另有依赖面、少一项说明该断言与「已索引」判据无关)。逐条=该断言确实钉在
954
+ #: 「区间哈希 + 行位全等才算已索引」上,全部由**同一次**变异实跑得出:
955
+ #: 2a 同标题异内容(真源换同长度正文)⇒ updated > 0 —— 「id 撞即跳」下 updated == 0
956
+ #: 2b updated == 前态快照判「需覆写」的条数(独立数据面交叉核验)—— 变异下 0 而期望 > 0
957
+ #: 3a 两代对账 rc == 0 —— 变异下 updated != updated_expected ⇒ ok=False ⇒ rc=1
958
+ #: 3d 两代错位数 == 0 —— 未覆写的节点仍记 v1 行位,locate 到插入后的别的章
959
+ #: 3h 两代 updated == updated_expected
960
+ #: 3k 新式节点「认领自己」:变异下旧节点认领到别人的 heading_path
961
+ #: 3n 同 token 会话下的 rc=0 与缺/错位全 0(同 3a+3d)
962
+ #: 7i 插入后重跑「覆写计数非零」(不静默)
963
+ #: 7j 插入后重跑「旧 id 全部仍可读且哈希一致」—— 变异下哈希仍指 v1 区间 ⇒ 不一致
964
+ #: 7k 重跑后检索命中 → 区间回读**逐字节 + hash_match**(变异下 hash_match=False)
965
+ #: 7l 生产读面 op=ref read 的 hash_match —— 同上(同一单点 read_ref)
966
+ #: 7m 生产读面 with_src=True 的区间状态与真源状态 —— 变异下节点带旧区间哈希**与旧
967
+ #: 真源身份**(doc_ref.src 一并冻结)⇒ span=stale 且 src=stale
968
+ #: 7n 转写被清理时应为「区间 dangling 而 **src 仍 ok**」—— 同一冻结使 src 报 stale
969
+ EXPECTED_RED = ("2a", "2b", "3a", "3d", "3h", "3k", "3n", "7i", "7j", "7k", "7l",
970
+ "7m", "7n")
971
+
972
+
973
+ # 生效条件:锚点命中次数不是恰好 1 时由 _mutated_node_state 抛出(fail-closed)。
974
+ class AnchorMiss(RuntimeError):
975
+ pass
976
+
977
+
978
+ #: 导入时的实现体字节码(红基线模式会把变异体挂回 logref.node_state,故不能与属性现值比)
979
+ _IMPL_CODEC = logref.node_state.__code__.co_code
980
+
981
+
982
+ # 生效条件:锚点仍在且 EXPECTED_RED 全部是本文件里真实存在的 check id 时静默返回;
983
+ # 否则抛 AnchorMiss(供缺依赖 zstandard 的 SKIP 出口复用,免得该环境里本守卫成纯空转)。
984
+ def _anchor_selfcheck() -> None:
985
+ _mutated_node_state() # 锚点漂移/歧义 → AnchorMiss
986
+ with open(os.path.abspath(__file__), encoding="utf-8") as f:
987
+ src = f.read()
988
+ gone = [i for i in EXPECTED_RED if ('ck("%s[' % i) not in src]
989
+ if gone:
990
+ raise AnchorMiss("EXPECTED_RED 含本文件里不存在的 check id(红项表已陈化):%s"
991
+ % "、".join(gone))
992
+
993
+
994
+ # 生效条件:读工作区 md_cg/logref.py(**只读,不落盘**),按 _MUTATIONS 逐处文本替换,
995
+ # 把变异后的模块体 exec 进真模块 globals 的副本,返回变异后的 node_state 函数;
996
+ # 锚点漂移/歧义时抛 AnchorMiss。
997
+ def _mutated_node_state():
998
+ p = os.path.join(REPO, "md_cg", "logref.py")
999
+ with open(p, encoding="utf-8") as f:
1000
+ text = f.read()
1001
+ for label, anchor, repl in _MUTATIONS:
1002
+ n = text.count(anchor)
1003
+ if n != 1:
1004
+ raise AnchorMiss(
1005
+ "ANCHOR-MISS:定点变异锚点漂移/歧义 —— %s 命中 %d 次(应恰好 1 次)"
1006
+ "|锚点首行:%s" % (label, n, anchor.splitlines()[0].strip()))
1007
+ text = text.replace(anchor, repl)
1008
+ g = dict(vars(logref)) # __name__/__package__ 保留 ⇒ 相对 import 照旧解析
1009
+ exec(compile(text, "<mutated md_cg/logref.py>", "exec"), g)
1010
+ fn = g["node_state"]
1011
+ # 与**导入时**的实现体比(不能比当前属性:红基线模式会把变异体挂回该属性,
1012
+ # 同进程二次调用就会「自己等于自己」而误报 ANCHOR-MISS——探针实测踩过)
1013
+ if fn.__code__.co_code == _IMPL_CODEC:
1014
+ raise AnchorMiss("ANCHOR-MISS:变异后字节码与实现相同(替换未生效)")
1015
+ return fn
1016
+
1017
+
1018
+ # ---------------- 主流程 ----------------
1019
+
1020
+ #: 守卫段(顺序即执行顺序;label 用于打印与崩溃定位)
1021
+ SECTIONS = (
1022
+ ("⓪ 写面边界(库根只许 tempfile;normcase 归一)",
1023
+ lambda tmp, sroot: guard_boundary(tmp)),
1024
+ ("① 幂等 + ② 同标题异内容(含变异必红)", guard_idem_and_content),
1025
+ ("③ 插话两代对账", guard_two_generations),
1026
+ ("③' 派生标识碰撞(N167 面)下的两代对账", guard_token_collision),
1027
+ ("④ 悬空(两层解耦 + regenerable 免清退)", guard_dangling),
1028
+ ("④'' heal 重建后「可再生」仍生效", guard_regenerable_after_rebuild),
1029
+ ("④' 离线再生(--repair 1)", guard_repair),
1030
+ ("⑤ 密级跟源(含加密告警计数)", guard_sensitivity),
1031
+ ("⑥ path 口径(含同 sid 跨 ws 撞 id 前提)", guard_path_convention),
1032
+ ("⑦ P2 端到端:检索命中 → 区间回读真源", guard_p2_search_readback),
1033
+ )
1034
+
1035
+
1036
+ def main(argv=None) -> int:
1037
+ global TOOL, PASS, FAIL
1038
+ PASS = FAIL = 0 # 同进程多次调用(探针/自证)不累积计数
1039
+ FAILS.clear()
1040
+ RED.clear()
1041
+ ap = argparse.ArgumentParser(
1042
+ description="N203 P1 守卫:日志真源 → ref 索引(临时库)")
1043
+ ap.add_argument("--head-baseline", action="store_true",
1044
+ help="红基线:对工作区 md_cg/logref.py 做定点变异(落库判据改回"
1045
+ "「id 撞即跳」),断言红项集合 == 预期红项")
1046
+ args = ap.parse_args(argv)
1047
+ try:
1048
+ import zstandard # noqa: F401
1049
+ except ImportError:
1050
+ # 缺依赖时正文链路无法复核(SKIP,对齐 run_tests 裸 clone 不假红口径),但
1051
+ # **判别力自证仍跑**:锚点漂移 / 红项表陈化依旧 fail-closed——否则本守卫在没有
1052
+ # zstandard 的环境(CI 容器只装 pyyaml)里就是纯空转。
1053
+ try:
1054
+ _anchor_selfcheck()
1055
+ except AnchorMiss as exc:
1056
+ print("!! %s" % exc)
1057
+ print("结果:SKIP 态自证未过(fail-closed)")
1058
+ return 2
1059
+ print("SKIP md_cg.test_logref:缺依赖 zstandard(日志转写链路无法复核)"
1060
+ "——对齐 run_tests 裸 clone 不假红口径;锚点/红项表自证已跑且通过")
1061
+ return 0
1062
+ if hasattr(sys.stdout, "reconfigure"):
1063
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
1064
+ TOOL = load_tool()
1065
+ # 锚点自证:**两态都查**(绿态也查)——锚点漂移后若只查红基线,绿线会继续绿着而
1066
+ # 判别力已死(本守卫的判别力全靠这条表)。漂移即 fail-closed。
1067
+ try:
1068
+ mut_state = _mutated_node_state()
1069
+ except AnchorMiss as exc:
1070
+ print("!! %s" % exc)
1071
+ print("结果:锚点自证未过(fail-closed)——定点变异表须同步实现")
1072
+ return 2
1073
+ if args.head_baseline:
1074
+ logref.node_state = mut_state # 变异体注入(只在本进程内)
1075
+ print("!! 红基线模式:md_cg/logref.py 经定点变异 —— %s ⇒ 「id 撞即跳」"
1076
+ % ";".join(lbl for lbl, _a, _r in _MUTATIONS))
1077
+ tmp = tempfile.mkdtemp(prefix="n203_p1_guard_")
1078
+ try:
1079
+ sroot = make_fixture(tmp)
1080
+ for label, fn in SECTIONS:
1081
+ print("== %s ==" % label)
1082
+ try:
1083
+ fn(tmp, sroot)
1084
+ except Exception as exc: # 段内崩溃:计数为红项,不吞、不静默
1085
+ ck("EXC:%s" % label, False, "%s: %s\n%s"
1086
+ % (type(exc).__name__, exc, traceback.format_exc()[-400:]))
1087
+ finally:
1088
+ shutil.rmtree(tmp, ignore_errors=True)
1089
+ print("\n结果:PASS %d / FAIL %d" % (PASS, FAIL))
1090
+ if FAILS:
1091
+ print("失败项:" + ";".join(FAILS))
1092
+ if args.head_baseline:
1093
+ # 短码比对:ck 的 cid 带方括号内的说明(同一短码可多次出现,如 0a 六个拒绝面),
1094
+ # 取方括号前的前缀——段内崩溃记的 "EXC:<段名>" 无方括号 ⇒ 整串参与比对(必不等于
1095
+ # 任何预期红项 ⇒ 报「红基线与预期不符」,fail-closed,不静默放过)。
1096
+ got = sorted({(c or "").split("[")[0].strip() for c in RED})
1097
+ exp = sorted(EXPECTED_RED)
1098
+ if got == exp:
1099
+ print("红基线符合预期:变异后恰好命中 %d 项 —— %s"
1100
+ % (len(exp), "、".join(exp)))
1101
+ return 0
1102
+ print("红基线与预期不符(fail-closed)——预期红项:%s;实得红项:%s"
1103
+ % ("、".join(exp) or "(无)", "、".join(got) or "(无)"))
1104
+ return 1
1105
+ return 1 if FAIL else 0
1106
+
1107
+
1108
+ if __name__ == "__main__":
1109
+ raise SystemExit(main())