@furongjun1999/dsh-memory 0.6.0 → 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 (170) hide show
  1. package/README.md +588 -572
  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/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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  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_v25.md +230 -0
  23. 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
  24. 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
  25. package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
  26. 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
  27. package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. package/lib/bridge.js +24 -2
  35. package/lib/hooks.d.ts +31 -2
  36. package/lib/hooks.js +219 -16
  37. package/lib/init.js +16 -1
  38. package/lib/lib/prompt_safety.d.ts +52 -1
  39. package/lib/lib/prompt_safety.js +76 -1
  40. package/md_cg/audit.py +652 -379
  41. package/md_cg/bench6_arms.py +1 -1
  42. package/md_cg/bench_p0.py +1 -1
  43. package/md_cg/branches.py +2 -2
  44. package/md_cg/ccgc.py +1071 -1006
  45. package/md_cg/chain.py +1 -1
  46. package/md_cg/consistency.py +97 -9
  47. package/md_cg/consolidate.py +34 -9
  48. package/md_cg/crypto.py +59 -26
  49. package/md_cg/docindex.py +16 -1
  50. package/md_cg/evolution.py +17 -1
  51. package/md_cg/export.py +1 -1
  52. package/md_cg/forgetting.py +309 -13
  53. package/md_cg/fsutil.py +454 -8
  54. package/md_cg/identity.py +3 -3
  55. package/md_cg/insight.py +24 -3
  56. package/md_cg/linkref.py +1 -1
  57. package/md_cg/logref.py +327 -0
  58. package/md_cg/mcp_server.py +4152 -3818
  59. package/md_cg/mdcg.py +4208 -3547
  60. package/md_cg/mdcos.py +4491 -4159
  61. package/md_cg/mreview/pipeline.py +15 -1
  62. package/md_cg/nodefile.py +639 -575
  63. package/md_cg/protect.py +158 -11
  64. package/md_cg/protocol.py +41 -2
  65. package/md_cg/provenance.py +22 -4
  66. package/md_cg/reach.py +4 -4
  67. package/md_cg/readcache.py +76 -15
  68. package/md_cg/reconcile.py +1 -1
  69. package/md_cg/refindex.py +246 -37
  70. package/md_cg/refine.py +1 -1
  71. package/md_cg/routing.py +32 -5
  72. package/md_cg/run_tests.py +17 -0
  73. package/md_cg/scrub.py +30 -3
  74. package/md_cg/security.py +47 -1
  75. package/md_cg/self_state.py +5 -5
  76. package/md_cg/sources.py +23 -6
  77. package/md_cg/srcindex.py +352 -0
  78. package/md_cg/stg.py +47 -3
  79. package/md_cg/subgraph.py +2 -2
  80. package/md_cg/sustain.py +1299 -1168
  81. package/md_cg/tasks.py +469 -470
  82. package/md_cg/test_b1_auto_id_multiproc.py +277 -0
  83. package/md_cg/test_b1b2_write_face.py +417 -0
  84. package/md_cg/test_b2_sensitivity_landing.py +223 -0
  85. package/md_cg/test_b3_merge_keeps_content.py +576 -0
  86. package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
  87. package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
  88. package/md_cg/test_c3_transient_read_negative.py +793 -0
  89. package/md_cg/test_c8_search_rrf_gates.py +502 -0
  90. package/md_cg/test_ccg_form_parity.py +188 -0
  91. package/md_cg/test_ccgc.py +28 -3
  92. package/md_cg/test_govern_directread.py +17 -4
  93. package/md_cg/test_h2_session_view_norm.py +233 -0
  94. package/md_cg/test_h4_sustain_snapshot.py +1377 -0
  95. package/md_cg/test_hive_ingest.py +285 -285
  96. package/md_cg/test_issue39_utf8_stdio.py +307 -16
  97. package/md_cg/test_issue43_default_policy.py +865 -0
  98. package/md_cg/test_legacy_p3_node_id_type.py +375 -0
  99. package/md_cg/test_linkref.py +10 -3
  100. package/md_cg/test_lock.py +2 -2
  101. package/md_cg/test_logref.py +1109 -0
  102. package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
  103. package/md_cg/test_m3_h9_semantic_guard.py +525 -0
  104. package/md_cg/test_mr_m2.py +15 -3
  105. package/md_cg/test_n130_verify_falsified_protect.py +1 -1
  106. package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
  107. package/md_cg/test_n176_link_trust.py +221 -0
  108. package/md_cg/test_n178_units_jobid_gate.py +212 -0
  109. package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
  110. package/md_cg/test_n195_writepath_reload.py +283 -0
  111. package/md_cg/test_n196_stale_gate_skip.py +169 -0
  112. package/md_cg/test_n197_n208_write_face_gates.py +471 -0
  113. package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
  114. package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
  115. package/md_cg/test_n201_proposal_visibility.py +382 -0
  116. package/md_cg/test_n202_session_notes_visibility.py +461 -0
  117. package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
  118. package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
  119. package/md_cg/test_n209_verify_write_face_gates.py +461 -0
  120. package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
  121. package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
  122. package/md_cg/test_n225_nonobject_load.py +1110 -0
  123. package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
  124. package/md_cg/test_neg_condition_hits.py +333 -0
  125. package/md_cg/test_neg_tail_honesty.py +663 -0
  126. package/md_cg/test_none_id_write_guard.py +4 -3
  127. package/md_cg/test_opt_batch1_md_cg.py +451 -0
  128. package/md_cg/test_p1.py +5 -5
  129. package/md_cg/test_p11_consistency.py +161 -12
  130. package/md_cg/test_p26_refindex.py +2 -2
  131. package/md_cg/test_p27_docindex.py +777 -774
  132. package/md_cg/test_p28_refcheck.py +740 -13
  133. package/md_cg/test_p29_session_ingest_export.py +2 -2
  134. package/md_cg/test_p2_mcp.py +12 -1
  135. package/md_cg/test_p30_maintain.py +33 -2
  136. package/md_cg/test_p31_insight.py +1 -0
  137. package/md_cg/test_p9c_dedup_hints.py +276 -0
  138. package/md_cg/test_policy_required_ccg.py +426 -0
  139. package/md_cg/test_protocol.py +22 -0
  140. package/md_cg/test_rank_parity_score_mode.py +516 -0
  141. package/md_cg/test_read_face_input_gates.py +363 -0
  142. package/md_cg/test_read_face_semantics.py +489 -0
  143. package/md_cg/test_recall_face_guards.py +812 -0
  144. package/md_cg/test_rejected_credential_forms.py +188 -0
  145. package/md_cg/test_rejected_redact.py +146 -0
  146. package/md_cg/test_retr_s1b.py +2 -2
  147. package/md_cg/test_retr_s5.py +20 -7
  148. package/md_cg/test_review_conformance.py +21 -3
  149. package/md_cg/test_security_audit_v21.py +2 -2
  150. package/md_cg/test_server_version.py +67 -0
  151. package/md_cg/test_srcindex.py +171 -0
  152. package/md_cg/test_tenant_env_override_warn.py +63 -50
  153. package/md_cg/test_token_lowercase_form.py +315 -0
  154. package/md_cg/test_v21r1_package_version.py +310 -0
  155. package/md_cg/test_writepipe.py +10 -4
  156. package/md_cg/tokens.py +225 -95
  157. package/md_cg/tool_face.py +4 -4
  158. package/md_cg/trust.py +49 -5
  159. package/md_cg/units.py +204 -6
  160. package/md_cg/weights.py +4 -4
  161. package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
  162. package/md_cg/writelimit.py +42 -8
  163. package/md_cg/writepipe.py +651 -554
  164. package/package.json +3 -2
  165. package/skills/plugin.json +1 -1
  166. package/src/bridge.ts +25 -2
  167. package/src/hooks.ts +229 -16
  168. package/src/init.ts +15 -1
  169. package/src/lib/prompt_safety.ts +88 -1
  170. package/zcode/AGENTS.md +10 -10
@@ -14,28 +14,51 @@
14
14
  `rebuild_refs` 重建派生物后漂移消除(源文件不动)。
15
15
  ⑥ 检索结果带 `ref` / `ref_kind`(读侧只加字段,不改召回逻辑)。
16
16
  ⑦ 派生物不膨胀:`_refindex.json` 体积有界、重跑不涨。
17
+ ⑧ **判据分工**(#4):漂移 / 悬空只由**节点自身 frontmatter ref** 判,水位只判
18
+ 「这个节点能不能跳过、不读源」(五项全等 + 源 size/mtime 未变 + 同 id 只判一次);
19
+ 水位与节点不一致记 `ledger_mismatch` 归因行(带两侧 root),**不进 ok、不重复报红**。
20
+ ⑨ **撞 id 现场**(D1 现状钉子):两域同 rel path + 同 name ⇒ 同 id、节点被后写域覆盖,
21
+ 但归因行可见。本组断言**现状事实**(+ 去重 + 归因字段),不作期望语义断言——
22
+ `node_id` 一旦改为含 root,① 会转红以提示同步迁移。
23
+ ⑩ **口径钉子**(#5):id 是**位置化**寻址(`sha1(路径::符号名)` / `sha1(路径#标题路径)`),
24
+ 幂等判据是**区间哈希 + 行位全等**(不是「id 存在即已索引」)。两侧都钉——实现侧
25
+ 手算 sha1 独立复算 + 对 root / 正文 / 行位不敏感 + 五项逐项不跳过;文档侧
26
+ `docs/plans/真源索引_通用机制_v0.3.md` §3.1 的口径字面、整块指纹(**陈化硬失败**)
27
+ 与行级引用必须仍与实现同源(引用漂移即红,失败信息附现址)。
28
+ ⑪ **常驻循环止血**:`SustainLoop._tick_heal` 按 `repeat_guard=True` 调 heal——同因重复
29
+ 失败被拦下(`repeat_failure` + 退避)而**诊断面照旧报病灶**(判据不被削弱)、
30
+ `heals[*].failed` 单列(没修好不静默、不假装成功)。
31
+ 红基线(判别力自证,见 `_MUTATIONS` / `EXPECTED_RED`):`--head-baseline` 对
32
+ **工作区 `md_cg/refindex.py`** 做定点变异(快路径探测数据改回水位条目=v1 旧行为),
33
+ 断言红项集合**恰好**等于预期三项(⑩ / ⑪ 两段钉的是别的语义,变异态下**仍绿**,
34
+ 故不入红项表——「恰好」二字正是这么被守住的)。
17
35
 
18
- 运行:python -m md_cg.test_p28_refcheck
36
+ 运行:python -m md_cg.test_p28_refcheck [--head-baseline]
19
37
  """
20
38
  from __future__ import annotations
21
39
 
40
+ import argparse
41
+ import hashlib
22
42
  import os
43
+ import re
23
44
  import shutil
24
45
  import sys
25
46
  import tempfile
47
+ import time
26
48
 
27
- from . import corpus, refindex, tokens
49
+ from . import corpus, refindex, sources, sustain, tokens
28
50
  from .mdcg import MdCG
29
51
  from .mcp_server import call_tool
30
52
 
31
53
  PASS = FAIL = 0
32
54
  FAILS = []
55
+ RED = [] # 转红项 cid(红基线模式比对用;绿态为空)
33
56
 
34
57
  _BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35
58
  ROOT = os.path.join(_BASE, "_md_cg_p28")
36
59
 
37
60
 
38
- def check(name, cond, detail=""):
61
+ def check(name, cond, detail="", cid=None):
39
62
  global PASS, FAIL
40
63
  if cond:
41
64
  PASS += 1
@@ -43,6 +66,9 @@ def check(name, cond, detail=""):
43
66
  else:
44
67
  FAIL += 1
45
68
  FAILS.append(name)
69
+ # 无 cid 的失败记哨兵:红基线模式下它会与预期红项不等 ⇒ fail-closed,
70
+ # 不静默放过「变异波及了没被登记的断言」(同 test_logref.py:66 的口径)。
71
+ RED.append(cid or ("?" + name))
46
72
  print(f" [FAIL] {name} · {detail}")
47
73
 
48
74
 
@@ -65,7 +91,7 @@ ALPHA = ('"""能量计算模块。"""\n'
65
91
  # 同函数同区间,只改一行实现 → 行数不变,只有区间哈希变(最强的漂移信号)
66
92
  ALPHA_DRIFT = ALPHA.replace("return mass * speed * speed",
67
93
  "return 0.5 * mass * speed * speed")
68
- # 第二个漂移变体:段⑤ 已把源改成 ALPHA_DRIFT 并重建索引,段⑧ 要再制造一次真实漂移,
94
+ # 第二个漂移变体:段⑤ 已把源改成 ALPHA_DRIFT 并重建索引,段⑨ 要再制造一次真实漂移,
69
95
  # 必须写出**与当前索引内容不同**的正文;否则只是 mtime 变了、内容没变,报 ok 才对。
70
96
  ALPHA_DRIFT2 = ALPHA.replace("return mass * speed * speed",
71
97
  "return mass * speed * speed / 2.0")
@@ -105,7 +131,677 @@ def ref_read(cg, **args):
105
131
  return call_tool(cg, "cg", a)
106
132
 
107
133
 
108
- def main():
134
+ # ---------------- 6 判据分工:节点 ref 判漂移,水位只判跳过 ----------------
135
+
136
+ #: 合成源:改一行实现 → 行数不变、只有区间哈希变(最强的漂移信号,同 ALPHA_DRIFT)。
137
+ PROBE_SRC = ('"""probe 模块。"""\n'
138
+ '\n'
139
+ '\n'
140
+ 'def probe_fn(x):\n'
141
+ ' """probe 函数。"""\n'
142
+ ' return x + 1\n')
143
+ PROBE_SRC2 = PROBE_SRC.replace("return x + 1", "return x + 2")
144
+
145
+
146
+ def _mk(tmp, name):
147
+ d = os.path.join(tmp, name)
148
+ os.makedirs(d)
149
+ return d
150
+
151
+
152
+ def _index_into(cg, src, ledger=None):
153
+ """直调 refindex 的两步(index_dir → add_items),返回本轮节点 id。
154
+
155
+ 必须直调:`op=index_code` 是这两步的**连写**,表达不了「只写一侧」的分歧。
156
+ """
157
+ items, _errors, _stats = refindex.index_dir(src, kind="code_ref",
158
+ ledger=ledger)
159
+ ids, _sens = refindex.add_items(cg, items, kind="code_ref", root=src)
160
+ return ids
161
+
162
+
163
+ def _tamper_ref_hash(node_path, fake="deadbeefcafe"):
164
+ """把节点 .md 里 frontmatter 的 ref 哈希改成假值(源与水位都不动)。
165
+
166
+ `cg.get` 每次从盘上读节点,故改 .md 即改「节点自述」;水位条目不受影响——
167
+ 这正是「漂移只由节点 ref 判」要区分的场景(v1 拿水位当探测数据 ⇒ 静默)。
168
+ """
169
+ with open(node_path, encoding="utf-8") as f:
170
+ txt = f.read()
171
+ out = re.sub(r'"hash": "[0-9a-f]{12}"', '"hash": "%s"' % fake, txt, count=1)
172
+ if out == txt:
173
+ raise RuntimeError("夹具失效:节点 frontmatter 里没有可篡改的 ref.hash")
174
+ with open(node_path, "w", encoding="utf-8") as f:
175
+ f.write(out)
176
+
177
+
178
+ def _verdict(res):
179
+ """巡检结果 → 去重/归因视角的摘要(红基线断言只看这里)。"""
180
+ ids = [r.get("node_id") for r in list(res.get("stale") or [])
181
+ + list(res.get("dangling") or [])]
182
+ return {"ok": res["ok"], "ids": ids, "dup": len(ids) - len(set(ids)),
183
+ "mm": res.get("ledger_mismatch") or [],
184
+ "mm_count": res.get("ledger_mismatch_count", 0)}
185
+
186
+
187
+ def guard_verdict_owner(tmp):
188
+ """三组构造:水位与节点分别推到不同代,看快路径认谁(直调 refindex)。"""
189
+ # ①水位新、节点旧 —— 等价 mcp_server.py 的 index_dir→add_items 之间被杀
190
+ lib = _mk(tmp, "fo_lib")
191
+ src = _mk(tmp, "fo_src")
192
+ p = os.path.join(src, "probe_widget.py")
193
+ wr(p, PROBE_SRC)
194
+ cg = MdCG(lib)
195
+ ids = _index_into(cg, src, refindex.Ledger(lib))
196
+ cg.flush()
197
+ wr(p, PROBE_SRC2, bump=5.0)
198
+ refindex.index_dir(src, kind="code_ref", ledger=refindex.Ledger(lib))
199
+ res = refindex.check_refs(cg, ledger=refindex.Ledger(lib))
200
+ v = _verdict(res)
201
+ node_hashes = {refindex.ref_of(cg.get(n))[1]["hash"] for n in ids}
202
+ check("6①只推水位:ok=False(漂移由节点 ref 判,水位说了不算)",
203
+ res["ok"] is False, f"ok={res['ok']} stale={len(res['stale'])}", cid="A1")
204
+ check("6①stale 覆盖该文件全部节点,hash_expected 取节点 ref(不是水位新哈希)",
205
+ {r["node_id"] for r in res["stale"]} == set(ids)
206
+ and {r["hash_expected"] for r in res["stale"]} == node_hashes,
207
+ f"stale={sorted(r['node_id'] for r in res['stale'])}", cid="A1")
208
+ check("6①水位/节点不一致记归因行 watermark_behind(写序断裂可见,不进 ok)",
209
+ v["mm_count"] >= 1
210
+ and all(r["kind_of_gap"] == "watermark_behind" for r in v["mm"]),
211
+ str(v["mm"][:1]), cid="A2")
212
+
213
+ # ②节点新、水位旧 —— ledger 根本不参与,才算「只推节点」
214
+ lib = _mk(tmp, "fn_lib")
215
+ src = _mk(tmp, "fn_src")
216
+ p = os.path.join(src, "probe_widget.py")
217
+ wr(p, PROBE_SRC)
218
+ cg = MdCG(lib)
219
+ _index_into(cg, src, refindex.Ledger(lib))
220
+ cg.flush()
221
+ wr(p, PROBE_SRC2, bump=5.0)
222
+ _index_into(cg, src, ledger=None) # 全量重切,水位不动
223
+ cg.flush()
224
+ res = refindex.check_refs(cg, ledger=refindex.Ledger(lib))
225
+ v = _verdict(res)
226
+ check("6②只推节点:ok=True 且 stale/dangling 空(旧水位不得误报漂移)",
227
+ res["ok"] is True and not res["stale"] and not res["dangling"],
228
+ f"ok={res['ok']} stale={sorted(v['ids'])}", cid="B1")
229
+ # 归因面与「红不红」解耦:这里只断言「不一致被记下来了」(ok 由 B1 断),
230
+ # 免得同一次误报把两条断言一起打红、红项表失去「一项一行为」的对应。
231
+ check("6②旧水位与节点不一致仍归因(watermark_behind)",
232
+ v["mm_count"] >= 1
233
+ and all(r["kind_of_gap"] == "watermark_behind" for r in v["mm"]),
234
+ str(v["mm"][:1]), cid="B2")
235
+
236
+ # ③篡改节点 ref.hash(源与水位都不动)—— 只有节点自述变了
237
+ lib = _mk(tmp, "ft_lib")
238
+ src = _mk(tmp, "ft_src")
239
+ p = os.path.join(src, "probe_widget.py")
240
+ wr(p, PROBE_SRC)
241
+ cg = MdCG(lib)
242
+ ids = _index_into(cg, src, refindex.Ledger(lib))
243
+ cg.flush()
244
+ nid = sorted(ids)[0]
245
+ _tamper_ref_hash(os.path.join(lib, cg.index["nodes"][nid]["path"]))
246
+ res = refindex.check_refs(cg, ledger=refindex.Ledger(lib))
247
+ v = _verdict(res)
248
+ check("6③篡改节点 ref.hash(源与水位都不动):必须报 stale",
249
+ res["ok"] is False and any(r["node_id"] == nid for r in res["stale"]),
250
+ f"ok={res['ok']} stale={v['ids']}", cid="C1")
251
+ check("6③同一 node_id 至多一行(去重,不重复报红)", v["dup"] == 0,
252
+ str(v["ids"]), cid="C2")
253
+
254
+
255
+ def guard_collision(tmp):
256
+ """撞 id 现场(D1 状态):钉现状事实 + 去重 + 归因,不作期望语义断言。
257
+
258
+ `node_id` 不含 root ⇒ 两域同 rel path + 同 name 时撞 id、节点被**后写域**覆盖
259
+ (`add_items` 同 id upsert 整条替换)。本组断言这个**现状**(以及归因行可见、
260
+ 不重复报红);键一旦改为含 root,第一条会转红以提示同步迁移——那是迁移触发器,
261
+ 不是要实现的期望语义(见 docs/plans/真源索引_通用机制_v0.3.md「id 语义与设计债」)。
262
+ """
263
+ lib = _mk(tmp, "fc_lib")
264
+ domA = _mk(tmp, "fc_domA")
265
+ domB = _mk(tmp, "fc_domB")
266
+ for d in (domA, domB):
267
+ wr(os.path.join(d, "probe_widget.py"), PROBE_SRC)
268
+ cg = MdCG(lib)
269
+ ids_a = _index_into(cg, domA, refindex.Ledger(lib))
270
+ ids_b = _index_into(cg, domB, refindex.Ledger(lib))
271
+ cg.flush()
272
+ nid = sorted(ids_a)[0]
273
+ ref = refindex.ref_of(cg.get(nid))[1] or {}
274
+ check("7①两域同 rel path + 同 name ⇒ 同一 id(现状:id 不含 root)",
275
+ bool(ids_a) and set(ids_a) == set(ids_b), f"A={ids_a} B={ids_b}",
276
+ cid="D1")
277
+ check("7①同一 id 只留一条节点,ref.root 为后写域(现状钉子,非期望语义)",
278
+ len(cg.index["nodes"]) == len(ids_a)
279
+ and refindex._same_root(ref.get("root"), domB),
280
+ f"nodes={len(cg.index['nodes'])} root={ref.get('root')}", cid="D1")
281
+ _tamper_ref_hash(os.path.join(lib, cg.index["nodes"][nid]["path"]))
282
+ res = refindex.check_refs(cg, ledger=refindex.Ledger(lib))
283
+ v = _verdict(res)
284
+ check("7②该现场篡改节点 ref.hash:同一 node_id 至多一行(dup=0)",
285
+ v["dup"] == 0, str(v["ids"]), cid="D2")
286
+ check("7②水位/节点不一致记 ledger_mismatch 归因行(撞 id 不再静默)",
287
+ v["mm_count"] >= 1, str(v["mm_count"]), cid="D2")
288
+ check("7②归因行带两侧 root 且 gap=collision_multi_root(可归因到域)",
289
+ bool(v["mm"]) and all(r.get("watermark_root") and r.get("node_root")
290
+ and r.get("kind_of_gap") == "collision_multi_root"
291
+ for r in v["mm"]),
292
+ str(v["mm"][:1]), cid="D2")
293
+ # 该现场再推一步:**两域的源都改掉**(合法漂移,不是篡改)。v1 逐条水位条目各自
294
+ # 探测 ⇒ 同一个 node_id 被反复报(本夹具实测 HEAD 4 行 / dup=2 ⇒ v2 2 行 / dup=0,
295
+ # 两个数是该文件的两个节点各一行)。这一步才真正钉住「去重」,且不依赖篡改。
296
+ wr(os.path.join(domA, "probe_widget.py"), PROBE_SRC2, bump=5.0)
297
+ wr(os.path.join(domB, "probe_widget.py"), PROBE_SRC2, bump=5.0)
298
+ v = _verdict(refindex.check_refs(cg, ledger=refindex.Ledger(lib)))
299
+ check("7③两域源都改:同一 node_id 至多一行(v1 会按水位条目重复报)",
300
+ v["dup"] == 0 and bool(v["ids"]), str(v["ids"]), cid="D3")
301
+ check("7③撞 id 归因行仍在(collision_multi_root,带两侧 root)",
302
+ v["mm_count"] >= 1
303
+ and all(r.get("watermark_root") and r.get("node_root")
304
+ and r.get("kind_of_gap") == "collision_multi_root"
305
+ for r in v["mm"]),
306
+ str(v["mm"][:1]), cid="D3")
307
+
308
+
309
+ # ---------------- 12 自愈止血 + 水位保全(H1/H1b/H2/H3/H4/H5/H6 + 写序) ----------------
310
+
311
+ def guard_heal_bounded(tmp):
312
+ """水位不被写空 / 重建失败不许记成成功 / 同因不重试(直调 refindex|sustain)。
313
+
314
+ 全部用对称小夹具(每域 1 个源文件、2 个节点),断言的是**行为**而不是文案。
315
+ """
316
+ # ①水位保全(H1):目录仍在、零文件 ⇒ 空扫不对账,水位不被剪空
317
+ lib = _mk(tmp, "hb1_lib")
318
+ src = _mk(tmp, "hb1_src")
319
+ p = os.path.join(src, "hb1_widget.py")
320
+ wr(p, PROBE_SRC)
321
+ cg = MdCG(lib)
322
+ ids = _index_into(cg, src, refindex.Ledger(lib))
323
+ cg.flush()
324
+ os.remove(p) # 目录仍在、零文件
325
+ refindex.index_dir(src, kind="code_ref", ledger=refindex.Ledger(lib))
326
+ s = refindex.Ledger(lib).summary()
327
+ check("12①空扫不对账:水位条目与节点数保住([C1] 目录仍在零文件)",
328
+ s["files"] == 1 and s["nodes"] == len(ids),
329
+ f"files={s['files']} nodes={s['nodes']} ids={len(ids)}", cid="E1")
330
+ check("12①两面不打架:last_index 如实记 files=0 且 empty_scan=True",
331
+ (s.get("last_index") or {}).get("files") == 0
332
+ and (s.get("last_index") or {}).get("empty_scan") is True,
333
+ str(s.get("last_index"))[:120], cid="E1")
334
+
335
+ # ②悬空 root 不重建(H1b):不调 index_dir ⇒ 水位不被写成空
336
+ lib = _mk(tmp, "hb2_lib")
337
+ src = _mk(tmp, "hb2_src")
338
+ wr(os.path.join(src, "hb2_widget.py"), PROBE_SRC)
339
+ cg = MdCG(lib)
340
+ _index_into(cg, src, refindex.Ledger(lib))
341
+ cg.flush()
342
+ n_files = refindex.Ledger(lib).summary()["files"]
343
+ shutil.rmtree(src) # 源大域整个没了
344
+ r = refindex.rebuild(cg, ledger=refindex.Ledger(lib))
345
+ check("12②rebuild 遇悬空 root:不重建、ok=False、roots_missing 如实记",
346
+ r["ok"] is False and len(r["roots_missing"]) == 1 and r["indexed"] == 0
347
+ and any("已不是目录" in e for e in r["errors"]),
348
+ f"ok={r['ok']} missing={r['roots_missing']} err={r['errors'][:1]}", cid="E2")
349
+ check("12②悬空 root 不再把水位写成空([C3] files 保住 vs 现状 0)",
350
+ refindex.Ledger(lib).summary()["files"] == n_files,
351
+ f"files={refindex.Ledger(lib).summary()['files']} 期望 {n_files}", cid="E2")
352
+
353
+ # ③空扫组如实置 ok=False(H2)
354
+ lib = _mk(tmp, "hb3_lib")
355
+ src = _mk(tmp, "hb3_src")
356
+ p = os.path.join(src, "hb3_widget.py")
357
+ wr(p, PROBE_SRC)
358
+ cg = MdCG(lib)
359
+ _index_into(cg, src, refindex.Ledger(lib))
360
+ cg.flush()
361
+ os.remove(p)
362
+ r = refindex.rebuild(cg, ledger=refindex.Ledger(lib))
363
+ check("12③rebuild 空扫组:ok=False + errors + 逐组 empty_scan 如实回报",
364
+ r["ok"] is False and r["errors"] and r["rebuilt"]
365
+ and r["rebuilt"][0]["empty_scan"] is True
366
+ and r["rebuilt"][0]["files"] == 0,
367
+ str(r["rebuilt"])[:140], cid="E3")
368
+
369
+ # ④只有悬空 ⇒ heal 不重建(H3):止血点在「按 code 分派」
370
+ lib = _mk(tmp, "hb4_lib")
371
+ domA = _mk(tmp, "hb4_domA")
372
+ domB = _mk(tmp, "hb4_domB")
373
+ for d, nm in ((domA, "hb4_a.py"), (domB, "hb4_b.py")):
374
+ wr(os.path.join(d, nm), PROBE_SRC)
375
+ cg = MdCG(lib)
376
+ _index_into(cg, domA, refindex.Ledger(lib))
377
+ _index_into(cg, domB, refindex.Ledger(lib))
378
+ cg.flush()
379
+ shutil.rmtree(domB)
380
+ codes = {i["code"] for i in sustain.diagnose(cg)["issues"]}
381
+ acts = [a["code"] for a in sustain.heal(cg)["actions"]]
382
+ check("12④ref_dangling 不再触发重建(fix 字段无调度器消费,改它无效)",
383
+ "ref_dangling" in codes and "rebuild_refs" not in acts,
384
+ f"codes={sorted(codes)} actions={acts}", cid="E4")
385
+
386
+ # ⑤重建失败如实记 ok=False(H4)+ ⑥同因不重试(H6)
387
+ lib = _mk(tmp, "hb5_lib")
388
+ domA = _mk(tmp, "hb5_domA")
389
+ domB = _mk(tmp, "hb5_domB")
390
+ for d, nm in ((domA, "hb5_a.py"), (domB, "hb5_b.py")):
391
+ wr(os.path.join(d, nm), PROBE_SRC)
392
+ cg = MdCG(lib)
393
+ _index_into(cg, domA, refindex.Ledger(lib))
394
+ _index_into(cg, domB, refindex.Ledger(lib))
395
+ cg.flush()
396
+ key = (os.path.abspath(lib), "rebuild_refs")
397
+ sustain._HEAL_MEMO.pop(key, None)
398
+ p = os.path.join(domA, "hb5_a.py")
399
+ wr(p, PROBE_SRC2, bump=5.0) # 一个域漂移
400
+ shutil.rmtree(domB) # 另一个域悬空(重建必带 roots_missing)
401
+ h1 = sustain.heal(cg, repeat_guard=True, heal_interval=300.0)
402
+ row = next((a for a in h1["actions"] if a["code"] == "rebuild_refs"), None)
403
+ check("12⑤重建失败不得记成成功:applied=True 且 ok=False,并带结果摘要",
404
+ bool(row) and row["applied"] is True and row["ok"] is False
405
+ and bool((row.get("result") or {}).get("roots_missing")),
406
+ str(row)[:170], cid="E5")
407
+ wr(p, PROBE_SRC, bump=5.0) # 回到与上次相同的诊断信号
408
+ h2 = sustain.heal(cg, repeat_guard=True, heal_interval=300.0)
409
+ row2 = next((a for a in h2["actions"] if a["code"] == "rebuild_refs"), None)
410
+ check("12⑥同一信号重复失败:拦下并如实记 repeat_failure(不是静默、不重刷)",
411
+ bool(row2) and row2["applied"] is False
412
+ and row2.get("reason") == "repeat_failure"
413
+ and row2.get("streak") == 1 and row2.get("wait_s") == 300.0,
414
+ str(row2)[:170], cid="E6")
415
+ sustain._HEAL_MEMO[key]["ts"] = time.time() - 400 # 模拟退避窗已过
416
+ h3 = sustain.heal(cg, repeat_guard=True, heal_interval=300.0)
417
+ row3 = next((a for a in h3["actions"] if a["code"] == "rebuild_refs"), None)
418
+ check("12⑥退避窗过后放行重试(退避 ≠ 永久静默)",
419
+ bool(row3) and row3["applied"] is True, str(row3)[:170], cid="E6")
420
+ sustain._HEAL_MEMO.clear()
421
+
422
+ # ⑦sources 的水位面(H5):Ledger 没有 stat(),写错被 except 吞成 {}
423
+ st = sources.FileDispatcher(cg)._ledger_stat()
424
+ check("12⑦ingest stat 的水位面不再被 AttributeError 吞成空",
425
+ isinstance(st, dict) and "files" in st and "path" in st,
426
+ str(sorted(st))[:120], cid="E7")
427
+
428
+ # ⑧写序(#2):节点写入失败 ⇒ 水位不得先落盘
429
+ lib = _mk(tmp, "hb6_lib")
430
+ src = _mk(tmp, "hb6_src")
431
+ wr(os.path.join(src, "hb6_widget.py"), PROBE_SRC)
432
+ cg = MdCG(lib)
433
+ orig = refindex.add_items
434
+
435
+ # 生效条件:任何入参都抛 RuntimeError(把「节点写入失败」注入 op 面),用于验证水位未先落盘;
436
+ def _boom(*_a, **_k):
437
+ raise RuntimeError("夹具:节点写入失败")
438
+
439
+ refindex.add_items = _boom
440
+ raised = False
441
+ try:
442
+ index_read(cg, src)
443
+ except RuntimeError:
444
+ raised = True
445
+ finally:
446
+ refindex.add_items = orig
447
+ led = refindex.Ledger(lib)
448
+ check("12⑧节点写入失败时水位不落盘(残留方向=节点新/水位旧,不是反向)",
449
+ raised and not os.path.isfile(led.path),
450
+ f"raised={raised} ledger_exists={os.path.isfile(led.path)}", cid="E8")
451
+
452
+
453
+ # ---------------- 13 口径钉子:id 位置化 + 幂等判据由区间哈希承担(#5) ----------------
454
+
455
+ #: 口径真源(文档侧)。措辞与实现必须同源——改一边不改另一边即转红。
456
+ DOC_PLAN = os.path.join(_BASE, "docs", "plans", "真源索引_通用机制_v0.3.md")
457
+ #: 口径块定位:起=该标题行,止=下一个**二级**标题(`### ` 不算终止)。
458
+ DOC_SEC_HEAD = "### 3.1 "
459
+
460
+ #: §3.1(归一化后)的 sha1 前 16 位:**判据侧陈化硬失败**——口径一被改写(无论改真源
461
+ #: 还是改守卫)就必须同步本行,才谈得上「措辞与实现同源」。归一化=空白折叠 ⇒ 换行 /
462
+ #: 缩进 / 表格对齐**不**误红,只有措辞变化才红。失败信息里打印现算值,照抄即恢复。
463
+ DOC_SEC_FP = "08c3e8fc30b1be7c"
464
+
465
+ #: §3.1 里必须**逐字**出现的口径字面(缺一条 ⇒ 措辞已从实现侧漂开)。
466
+ DOC_REQUIRED = (
467
+ "路径-标题寻址", # id 是位置化寻址(不是内容寻址)
468
+ "sha1(路径::符号名)", # code_ 的口径公式
469
+ "sha1(路径#标题路径)", # doc_ 的口径公式
470
+ "不含 root、不含正文", # 位置化的两个「不含」
471
+ "id 存在即已索引", # D2:明令禁止的判据(幂等不许看 id)
472
+ "hash + 行位全等", # 幂等判据的实际承担者
473
+ )
474
+
475
+ #: 行级引用 → 实现符号:(文档里必须出现的引用字面, 文件, 行号, 该行应以什么开头)。
476
+ #: 「文档说谁在哪一行」是措辞里最硬的一句——引用漂移(代码增删行)即红,失败信息附
477
+ #: 该符号**现址**,照抄回文档与本表即恢复(陈化可自愈到「人只需抄一次」)。
478
+ DOC_CITATIONS = (
479
+ ("refindex.py:445", "md_cg/refindex.py", 445, "def _code_ref("),
480
+ ("refindex.py:459", "md_cg/refindex.py", 459, "def _doc_ref("),
481
+ ("refindex.py:503", "md_cg/refindex.py", 503,
482
+ "got = codeindex.region_hash(lines, lineno, end)"),
483
+ ("refindex.py:520", "md_cg/refindex.py", 520, "def read_ref("),
484
+ ("refindex.py:588", "md_cg/refindex.py", 588, "def check_refs("),
485
+ ("refindex.py:784", "md_cg/refindex.py", 784, "def prune_orphans("),
486
+ ("srcindex.py:71", "md_cg/srcindex.py", 71, "def src_id("),
487
+ ("srcindex.py:86", "md_cg/srcindex.py", 86, "def file_hash("),
488
+ ("logref.py:119", "md_cg/logref.py", 119, "def node_state("),
489
+ ("docindex.py:294", "md_cg/docindex.py", 294, "def node_id("),
490
+ )
491
+
492
+
493
+ def _doc_block(path: str, head: str) -> str:
494
+ """取文档里 `head` 起、到下一个**二级**标题前的正文块,空白折叠后返回。
495
+
496
+ 起行不在文档里返回 ""(由调用方判红,不留静默分支)。归一化只折空白——
497
+ 重排 / 换行 / 表格对齐不影响指纹,只有**措辞**变化才改指纹。
498
+ """
499
+ try:
500
+ with open(path, encoding="utf-8") as f:
501
+ lines = f.read().replace("\r\n", "\n").split("\n")
502
+ except OSError:
503
+ return "" # 文档缺失=口径真源不可读,交由调用方判红
504
+ start = next((i for i, ln in enumerate(lines) if ln.startswith(head)), None)
505
+ if start is None:
506
+ return ""
507
+ i = start + 1
508
+ while i < len(lines) and not (lines[i].startswith("## ")
509
+ and not lines[i].startswith("### ")):
510
+ i += 1
511
+ return re.sub(r"\s+", " ", "\n".join(lines[start:i])).strip()
512
+
513
+
514
+ def guard_id_and_idempotence(tmp):
515
+ """#5 口径钉子:id 位置化(不含 root / 正文 / 行位)、幂等判据由区间哈希 + 行位承担。
516
+
517
+ 两侧都钉,谁单方面漂开都转红:①**实现侧**——id 用手算 sha1 独立复算(不借实现),
518
+ 对 root / 正文哈希 / 行位不敏感;水位与节点 ref 五项全等才是跳过的前提(「id 存在」
519
+ 不构成已索引);「同 id 而内容变」必须判 stale。②**文档侧**——`v0.3` 计划 §3.1 的
520
+ 口径字面齐全、整块指纹不陈化、行级引用仍指向所声称的符号。
521
+ """
522
+ from . import codeindex, docindex
523
+
524
+ # ---- ①id 口径:位置化,公式可手算复算 ----
525
+ it = {"path": "pkg/probe_widget.py", "name": "probe_fn", "kind": "function",
526
+ "lineno": 4, "end": 6, "hash": "aaaa1111bbbb", "lang": "py",
527
+ "precise": True}
528
+ want_code = "code_" + hashlib.sha1(
529
+ b"pkg/probe_widget.py::probe_fn").hexdigest()[:12]
530
+ check("13①code_ == sha1(路径::符号名)[:12](手算交叉核验,非复用实现)",
531
+ codeindex.node_id(it) == want_code
532
+ and refindex.node_id_of(it, "code_ref") == want_code,
533
+ f"{codeindex.node_id(it)} vs {want_code}", cid="F1")
534
+ dit = {"path": "docs/probe.md", "heading_path": ["一", "二"], "heading": "二",
535
+ "level": 2, "lineno": 10, "end": 20, "hash": "cccc2222", "dup": 1}
536
+ want_doc = "doc_" + hashlib.sha1(
537
+ "docs/probe.md#一/二".encode("utf-8")).hexdigest()[:12]
538
+ check("13①doc_ == sha1(路径#标题路径)[:12](手算交叉核验)",
539
+ docindex.node_id(dit) == want_doc
540
+ and refindex.node_id_of(dit, "doc_ref") == want_doc,
541
+ f"{docindex.node_id(dit)} vs {want_doc}", cid="F1")
542
+ check("13②id 不读 root(跨域同 rel path 必然同 id —— D1 的机械根)",
543
+ codeindex.node_id(dict(it, root="/domA")) == want_code
544
+ and codeindex.node_id(dict(it, root="/domB")) == want_code
545
+ and docindex.node_id(dict(dit, root="/domA")) == want_doc,
546
+ cid="F2")
547
+ check("13②id 不读正文哈希与行位(改内容 / 挪行位 id 不变 ⇒ 位置化而非内容寻址)",
548
+ codeindex.node_id(dict(it, hash="deadbeefdead", lineno=99, end=120)) == want_code
549
+ and docindex.node_id(dict(dit, hash="zzzz9999", lineno=1, end=2)) == want_doc,
550
+ cid="F2")
551
+
552
+ # ---- ②幂等判据:水位只记「能不能跳过」,判据键 = 节点 ref 的五项 + 源 size/mtime ----
553
+ lib = _mk(tmp, "fx_lib")
554
+ src = _mk(tmp, "fx_src")
555
+ p = os.path.join(src, "probe_widget.py")
556
+ wr(p, PROBE_SRC)
557
+ cg = MdCG(lib)
558
+ ids = _index_into(cg, src, refindex.Ledger(lib))
559
+ cg.flush()
560
+ nid = sorted(ids)[0]
561
+ _k0, ref0 = refindex.ref_of(cg.get(nid))
562
+ ents = [e for e in (refindex.Ledger(lib).load().get("files") or {}).values()
563
+ if (e.get("path") or "").endswith("probe_widget.py")]
564
+ e = ents[0] if ents else {}
565
+ n0 = (e.get("nodes") or [{}])[0]
566
+ check("13③水位键 = 源文件绝对路径且只有一条(跨域不撞名)",
567
+ len(ents) == 1, f"{len(ents)} 条", cid="F3")
568
+ check("13③水位条目记的正是判据要用的五项(root/path/lineno/end/hash)+ 源 size/mtime",
569
+ {"root", "path", "size", "mtime", "kind", "nodes"} <= set(e)
570
+ and {"id", "lineno", "end", "hash"} <= set(n0), sorted(e), cid="F3")
571
+ check("13③稳态:水位与节点 ref 五项全等(跳过的前提真的成立)",
572
+ refindex._same_root(e.get("root"), (ref0 or {}).get("root"))
573
+ and refindex._norm_rel(e.get("path")) == refindex._norm_rel((ref0 or {}).get("path"))
574
+ and n0.get("hash") == (ref0 or {}).get("hash")
575
+ and n0.get("lineno") == (ref0 or {}).get("lineno")
576
+ and n0.get("end") == (ref0 or {}).get("end"),
577
+ f"watermark={n0} ref={ref0}", cid="F3")
578
+ # 逐项:五项全等 + 源未变 ⇒ 跳过;任一项不等 ⇒ 一律进探测(「id 存在」不构成已索引)
579
+ n_rec = {"id": nid, "lineno": n0.get("lineno"), "end": n0.get("end"),
580
+ "hash": n0.get("hash")}
581
+ e_rec = {"root": e.get("root"), "path": e.get("path")}
582
+ rel = refindex._norm_rel(e.get("path"))
583
+ base = refindex._fast_path_verdict(cg, n_rec, e_rec, rel, e.get("root"), True)
584
+ check("13④五项全等 + 源 size/mtime 未变 ⇒ 跳过(probe=None,不读源)",
585
+ base["covered"] is True and base["probe"] is None, str(base), cid="F4")
586
+ holes = []
587
+ for k, v in (("hash", "000000000000"), ("lineno", 999), ("end", 1000)):
588
+ n2 = dict(n_rec)
589
+ n2[k] = v
590
+ if refindex._fast_path_verdict(cg, n2, e_rec, rel, e.get("root"),
591
+ True)["probe"] is None:
592
+ holes.append(k)
593
+ if refindex._fast_path_verdict(cg, n_rec, e_rec, "other_widget.py",
594
+ e.get("root"), True)["probe"] is None:
595
+ holes.append("path")
596
+ v_root = refindex._fast_path_verdict(cg, n_rec, e_rec, rel,
597
+ os.path.join(src, "elsewhere"), True)
598
+ if v_root["probe"] is None:
599
+ holes.append("root")
600
+ check("13④五项任一项不等 ⇒ 一律不跳过(「id 存在」不构成已索引)", not holes,
601
+ f"漏判项={holes}", cid="F4")
602
+ check("13④跨域不一致归因 collision_multi_root 且带两侧 root(不静默、不改判定)",
603
+ (v_root.get("mismatch") or {}).get("kind_of_gap") == "collision_multi_root"
604
+ and bool((v_root.get("mismatch") or {}).get("watermark_root"))
605
+ and bool((v_root.get("mismatch") or {}).get("node_root")),
606
+ str(v_root.get("mismatch")), cid="F4")
607
+
608
+ # ---- ③同 id 而内容变:必须判漂移(id 只做身份,不承担幂等) ----
609
+ wr(p, PROBE_SRC2, bump=5.0) # 行数不变,只有区间哈希变
610
+ res = refindex.check_refs(cg, ledger=refindex.Ledger(lib))
611
+ row = next((r for r in res["stale"] if r["node_id"] == nid), None)
612
+ check("13⑤同 id 而区间哈希变(源改一行)⇒ 必须判 stale,且 hash_expected 取节点 ref",
613
+ res["ok"] is False and row is not None
614
+ and row.get("hash_expected") == (ref0 or {}).get("hash"),
615
+ f"ok={res['ok']} stale={sorted(r['node_id'] for r in res['stale'])}",
616
+ cid="F5")
617
+
618
+ # ---- ④文档侧:措辞 / 指纹 / 行级引用都必须与实现同源 ----
619
+ sec = _doc_block(DOC_PLAN, DOC_SEC_HEAD)
620
+ check("13⑥口径真源可读:文档 §3.1 块非空", bool(sec), DOC_PLAN, cid="F6")
621
+ missing = [lit for lit in DOC_REQUIRED if lit not in sec]
622
+ check("13⑥文档 §3.1 逐字含口径字面(id 位置化 + 幂等判据不看 id)", not missing,
623
+ f"缺字面={missing}", cid="F6")
624
+ fp = hashlib.sha1(sec.encode("utf-8")).hexdigest()[:16]
625
+ check("13⑥文档 §3.1 指纹未陈化(措辞一改即红,须同步 DOC_SEC_FP)",
626
+ fp == DOC_SEC_FP, f"现={fp} 判据侧={DOC_SEC_FP}(照抄现算值即恢复)",
627
+ cid="F6")
628
+ doc_text = ""
629
+ try:
630
+ with open(DOC_PLAN, encoding="utf-8") as f:
631
+ doc_text = f.read()
632
+ except OSError:
633
+ pass # 缺文档 ⇒ 每条引用都记「未出现」,逐条报红
634
+ bad = []
635
+ for literal, fpath, lineno, claim in DOC_CITATIONS:
636
+ if literal not in doc_text:
637
+ bad.append(f"{literal} 未出现在文档")
638
+ continue
639
+ lines = rd(os.path.join(_BASE, fpath)).replace("\r\n", "\n").split("\n")
640
+ got = lines[lineno - 1] if 0 < lineno <= len(lines) else ""
641
+ # 判据取 strip 后前缀:行内缩进不算「引用漂移」(只钉「这一行是不是这句」)
642
+ if not got.strip().startswith(claim):
643
+ now = next((i for i, ln in enumerate(lines, 1)
644
+ if ln.strip().startswith(claim)), "?")
645
+ bad.append(f"{fpath}:{lineno} 现为 {got.strip()[:36]!r}"
646
+ f"({claim.strip()} 现在 {fpath}:{now})")
647
+ check("13⑦文档行级引用仍指向所声称的符号(引用漂移即红,失败信息附现址)",
648
+ not bad, ";".join(bad[:3]), cid="F7")
649
+
650
+
651
+ # ---------------- 14 常驻循环止血(heal 在役面) ----------------
652
+
653
+ def guard_loop_tick_heal(tmp):
654
+ """`SustainLoop._tick_heal`:空转被止住 + 判据未被削弱(不静默、不假装修好)。
655
+
656
+ 直调 `_tick_heal()`(**不起线程、不打心跳戳**),`d` 显式落到临时目录——
657
+ 绝不写 ~/.mdcg。落点取循环自己的账(`heals` / `last_diagnose`)与 heal 的
658
+ 返回值,不断言内部实现细节。
659
+ """
660
+ lib = _mk(tmp, "lt_lib")
661
+ domA = _mk(tmp, "lt_domA")
662
+ domB = _mk(tmp, "lt_domB")
663
+ for d, nm in ((domA, "lt_a.py"), (domB, "lt_b.py")):
664
+ wr(os.path.join(d, nm), PROBE_SRC)
665
+ cg = MdCG(lib)
666
+ _index_into(cg, domA, refindex.Ledger(lib))
667
+ _index_into(cg, domB, refindex.Ledger(lib))
668
+ cg.flush()
669
+ sustain._HEAL_MEMO.pop((os.path.abspath(lib), "rebuild_refs"), None)
670
+ pa = os.path.join(domA, "lt_a.py")
671
+ wr(pa, PROBE_SRC2, bump=5.0) # 一域漂移
672
+ shutil.rmtree(domB) # 一域悬空 ⇒ 重建必带 roots_missing
673
+ loop = sustain.SustainLoop(cg, "p28_guard_loop", heal_interval=300.0,
674
+ auto_heal=True, d=os.path.join(tmp, "lt_sustain"))
675
+ orig = sustain.heal
676
+ seen = {}
677
+
678
+ def _spy(cg_, **kw):
679
+ res = orig(cg_, **kw)
680
+ seen["kw"] = kw
681
+ seen["actions"] = res.get("actions")
682
+ return res
683
+
684
+ sustain.heal = _spy
685
+ try:
686
+ loop._tick_heal()
687
+ finally:
688
+ sustain.heal = orig
689
+ check("14①常驻循环按 repeat_guard=True + heal_interval 调 heal(空转止在这一面)",
690
+ (seen.get("kw") or {}).get("repeat_guard") is True
691
+ and (seen.get("kw") or {}).get("heal_interval") == 300.0,
692
+ str(sorted((seen.get("kw") or {}))), cid="G1")
693
+ h1 = loop.heals[-1]
694
+ check("14②首轮真跑并如实记没修好:actions 含 rebuild_refs 且 failed 单列该码",
695
+ "rebuild_refs" in h1["actions"] and "rebuild_refs" in h1["failed"],
696
+ str(h1), cid="G2")
697
+ check("14③判据未被削弱:诊断面照旧报出病灶(ref_stale 仍在 last_diagnose)",
698
+ "ref_stale" in ((loop.last_diagnose or {}).get("issues") or []),
699
+ str(loop.last_diagnose), cid="G2")
700
+ # 同信号第二轮:先让信号与上次一致(重建已把索引推到新代 ⇒ 写回旧正文再漂一次)
701
+ wr(pa, PROBE_SRC, bump=5.0)
702
+ sustain.heal = _spy
703
+ try:
704
+ loop._tick_heal()
705
+ finally:
706
+ sustain.heal = orig
707
+ row2 = next((a for a in (seen.get("actions") or [])
708
+ if a.get("code") == "rebuild_refs"), None)
709
+ check("14④同一信号重复失败 ⇒ 被拦下并如实记 repeat_failure(不是又重跑一次)",
710
+ bool(row2) and row2.get("applied") is False
711
+ and row2.get("reason") == "repeat_failure" and row2.get("streak") == 1,
712
+ str(row2), cid="G3")
713
+ h2 = loop.heals[-1]
714
+ check("14④拦下也照样进账:heals 记该动作 + failed 单列,诊断面仍报病灶(都不静默)",
715
+ "rebuild_refs" in h2["actions"] and "rebuild_refs" in h2["failed"]
716
+ and "ref_stale" in ((loop.last_diagnose or {}).get("issues") or []),
717
+ str(h2), cid="G3")
718
+ sustain._HEAL_MEMO.clear()
719
+
720
+
721
+ # ---------------- 红基线(判别力自证) ----------------
722
+
723
+ #: 定点变异表:(标签, 现实现锚点, 改回后的旧行为)。
724
+ #: **源 = 工作区 md_cg/refindex.py 的文本**(不取 git HEAD:HEAD 在改动提交那一刻就等于
725
+ #: 实现本身,判别力静默失效——同款教训见 md_cg/test_logref.py 的 _MUTATIONS 头注)。
726
+ #: 锚点必须**恰好命中 1 次**:0 次=漂移、≥2 次=歧义,皆 ANCHOR-MISS → fail-closed。
727
+ _MUTATIONS = (
728
+ ("快路径探测数据:节点自身 frontmatter ref(现行)→ 水位条目(v1 旧行为)",
729
+ ' out["probe"] = {**ref, "path": ref.get("path") or rel,\n'
730
+ ' "root": ref.get("root") or src_root}',
731
+ ' out["probe"] = {**n, "path": rel, "root": src_root}'),
732
+ )
733
+
734
+ #: 变异态下**必须**转红的项:恰好命中这个集合(多一项少一项都报红):
735
+ #: A1 6①只推水位 ⇒ v1 拿水位当探测数据 ⇒ 哈希「匹配」而静默 ok=True(漏报)
736
+ #: B1 6②只推节点 ⇒ v1 拿旧水位当探测数据 ⇒ 旧哈希对不上新源(误报 stale)
737
+ #: C1 6③篡改节点 ref.hash ⇒ v1 根本不看节点自述 ⇒ 静默 ok=True
738
+ EXPECTED_RED = ("A1", "B1", "C1")
739
+
740
+
741
+ # 生效条件:锚点命中次数不是恰好 1 次时由 _mutated_verdict 抛出(fail-closed)。
742
+ class AnchorMiss(RuntimeError):
743
+ pass
744
+
745
+
746
+ #: 导入时的实现体字节码(红基线模式会把变异体挂回该属性,故不能与属性现值比)
747
+ _IMPL_CODEC = refindex._fast_path_verdict.__code__.co_code
748
+
749
+
750
+ def _anchor_selfcheck() -> None:
751
+ """锚点仍在、且 EXPECTED_RED 都是本文件里真实存在的 cid → 静默通过。"""
752
+ _mutated_verdict() # 锚点漂移/歧义 → AnchorMiss
753
+ with open(os.path.abspath(__file__), encoding="utf-8") as f:
754
+ src = f.read()
755
+ gone = [i for i in EXPECTED_RED if ('cid="%s"' % i) not in src]
756
+ if gone:
757
+ raise AnchorMiss("EXPECTED_RED 含本文件里不存在的 check id(红项表已陈化):%s"
758
+ % "、".join(gone))
759
+
760
+
761
+ def _mutated_verdict():
762
+ """读工作区 `md_cg/refindex.py`(**只读,不落盘**)按 _MUTATIONS 逐处文本替换,
763
+ 把变异后的模块体 exec 进真模块 globals 的副本,返回变异后的 _fast_path_verdict。
764
+ 锚点漂移/歧义时抛 AnchorMiss。"""
765
+ p = os.path.join(_BASE, "md_cg", "refindex.py")
766
+ with open(p, encoding="utf-8") as f:
767
+ text = f.read()
768
+ for label, anchor, repl in _MUTATIONS:
769
+ n = text.count(anchor)
770
+ if n != 1:
771
+ raise AnchorMiss(
772
+ "ANCHOR-MISS:定点变异锚点漂移/歧义 —— %s 命中 %d 次(应恰好 1 次)"
773
+ "|锚点首行:%s" % (label, n, anchor.splitlines()[0].strip()))
774
+ text = text.replace(anchor, repl)
775
+ g = dict(vars(refindex)) # __name__ / __package__ 保留 ⇒ 相对 import 照旧解析
776
+ exec(compile(text, "<mutated md_cg/refindex.py>", "exec"), g)
777
+ fn = g["_fast_path_verdict"]
778
+ # 与**导入时**的实现体比(不能比当前属性:红基线模式会把变异体挂回该属性)
779
+ if fn.__code__.co_code == _IMPL_CODEC:
780
+ raise AnchorMiss("ANCHOR-MISS:变异后字节码与实现相同(替换未生效)")
781
+ return fn
782
+
783
+
784
+ def main(argv=None):
785
+ ap = argparse.ArgumentParser(
786
+ description="P28 守卫:ref 增量索引 + 漂移/悬空巡检(R3)")
787
+ ap.add_argument("--head-baseline", action="store_true",
788
+ help="红基线:对工作区 md_cg/refindex.py 做定点变异(快路径探测"
789
+ "数据改回水位条目=v1 旧行为),断言红项集合 == 预期红项")
790
+ args = ap.parse_args(argv)
791
+ # 锚点自证:**两态都查**(绿态也查)——锚点漂移后若只查红基线,绿线会继续绿着
792
+ # 而判别力已死(本守卫的判别力全靠这张表)。漂移即 fail-closed。
793
+ try:
794
+ _anchor_selfcheck()
795
+ except AnchorMiss as exc:
796
+ print("!! %s" % exc)
797
+ print("结果:锚点自证未过(fail-closed)——定点变异表须同步实现")
798
+ return 2
799
+ if args.head_baseline:
800
+ # 变异体注入(只在本进程内,不落盘)
801
+ refindex._fast_path_verdict = _mutated_verdict()
802
+ print("!! 红基线模式:md_cg/refindex.py 经定点变异 —— %s ⇒ 快路径改用水位条目"
803
+ % ";".join(lbl for lbl, _a, _r in _MUTATIONS))
804
+
109
805
  print("=" * 68)
110
806
  print("md 认知图 P28 验收 · ref 增量索引 + 漂移/悬空巡检(R3)")
111
807
  print("=" * 68)
@@ -215,8 +911,16 @@ def main():
215
911
  check("重跑 index_code 后 stale 消除", chk2.get("ok") is True,
216
912
  str([r.get("path") for r in chk2.get("stale", [])]))
217
913
 
218
- # ============================================ ⑥ 悬空:dangling
219
- print("\n【6】悬空检测:删源文件 → dangling")
914
+ # ============================================ 6 判据分工(#4)
915
+ print("\n【6】判据分工:漂移只由节点 ref 判,水位只判「能不能跳过」")
916
+ guard_verdict_owner(tmp)
917
+
918
+ # ============================================ 7 撞 id 现场(D1 现状钉子)
919
+ print("\n【7】撞 id 现场:归因可见 + 不重复报红(现状钉子 / 迁移触发器)")
920
+ guard_collision(tmp)
921
+
922
+ # ============================================ 8 悬空:dangling
923
+ print("\n【8】悬空检测:删源文件 → dangling")
220
924
  os.remove(beta)
221
925
  chk3 = ref_read(cg, action="check")
222
926
  check("报出 dangling 且指向 beta.py",
@@ -231,8 +935,8 @@ def main():
231
935
  chk4.get("ok") is True and not chk4.get("dangling"),
232
936
  str([r.get("path") for r in chk4.get("dangling", [])]))
233
937
 
234
- # ============================================ ⑦ 截断不静默 + 水位留痕
235
- print("\n【7】截断显式上报 + 写进水位")
938
+ # ============================================ 9 截断不静默 + 水位留痕
939
+ print("\n【9】截断显式上报 + 写进水位")
236
940
  tr = index_read(cg, src, max_files=1)
237
941
  check("max_files=1 → truncated=True", tr.get("truncated") is True)
238
942
  check("truncated_reason 指出 max_files",
@@ -249,8 +953,8 @@ def main():
249
953
  (ref_read(cg, action="stat")["ledger"].get("last_index") or {})
250
954
  .get("truncated") is False)
251
955
 
252
- # ============================================ ⑧ 接入 sustain.diagnose / heal
253
- print("\n【8】接入 sustain:diagnose 报 ref_stale,heal 的 rebuild_refs 修复")
956
+ # ============================================ 10 接入 sustain.diagnose / heal
957
+ print("\n【10】接入 sustain:diagnose 报 ref_stale,heal 的 rebuild_refs 修复")
254
958
  wr(alpha, ALPHA_DRIFT2, bump=5.0)
255
959
  diag = call_tool(cg, "cg", {"op": "sustain", "action": "diagnose"})
256
960
  codes = {i["code"] for i in diag.get("issues", [])}
@@ -269,8 +973,8 @@ def main():
269
973
  check("heal 后 ref_stale 消除", "ref_stale" not in codes2, str(sorted(codes2)))
270
974
  check("heal 不修改源文件", ALPHA_MARK2 in rd(alpha))
271
975
 
272
- # ============================================ ⑨ 派生物不膨胀 + 未知 action
273
- print("\n【9】_refindex.json 不膨胀 / 未知 action 不静默")
976
+ # ============================================ 11 派生物不膨胀 + 未知 action
977
+ print("\n【11】_refindex.json 不膨胀 / 未知 action 不静默")
274
978
  led2 = refindex.Ledger(ROOT)
275
979
  p = led2.path
276
980
  size1 = os.path.getsize(p)
@@ -288,6 +992,18 @@ def main():
288
992
  silent = False
289
993
  check("未知 action 不静默成功", silent is False)
290
994
 
995
+ # ============================================ 12 自愈止血 + 水位保全
996
+ print("\n【12】自愈止血与水位保全(水位不被写空 / 失败不记成成功 / 同因不重试)")
997
+ guard_heal_bounded(tmp)
998
+
999
+ # ============================================ 13 口径钉子(#5)
1000
+ print("\n【13】口径钉子:id 位置化 + 幂等判据由区间哈希承担(文档↔实现同源)")
1001
+ guard_id_and_idempotence(tmp)
1002
+
1003
+ # ============================================ 14 常驻循环止血
1004
+ print("\n【14】常驻循环止血(repeat_guard / 不静默 / 不假装修好)")
1005
+ guard_loop_tick_heal(tmp)
1006
+
291
1007
  finally:
292
1008
  shutil.rmtree(tmp, ignore_errors=True)
293
1009
 
@@ -296,6 +1012,17 @@ def main():
296
1012
  if FAILS:
297
1013
  print("失败项:\n - " + "\n - ".join(FAILS))
298
1014
  print("=" * 68)
1015
+ if args.head_baseline:
1016
+ # 短码集合比对:变异后**恰好**命中预期红项(多一项少一项都报红)。无 cid 的
1017
+ # 失败在 check 里记了哨兵 "?<name>",故「波及到没登记的断言」也在此报红。
1018
+ got, exp = sorted(set(RED)), sorted(EXPECTED_RED)
1019
+ if got == exp:
1020
+ print("红基线符合预期:变异后恰好命中 %d 项 —— %s"
1021
+ % (len(exp), "、".join(exp)))
1022
+ return 0
1023
+ print("红基线与预期不符(fail-closed)——预期红项:%s;实得红项:%s"
1024
+ % ("、".join(exp) or "(无)", "、".join(got) or "(无)"))
1025
+ return 1
299
1026
  return 1 if FAIL else 0
300
1027
 
301
1028