@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
@@ -0,0 +1,1110 @@
1
+ # -*- coding: utf-8 -*-
2
+ """md_cg · 非对象形态索引 / 日志守卫(N225:一行 `null` / `[]` / `123` / `"abc"`
3
+ 崩掉整条装载链)+ **补强批**(深度 2 同族面:排序键坏型 / 载荷坏型)
4
+
5
+ 缺陷(修复前,本守卫红态实抓):
6
+ ``json.loads`` 的产物不必是对象——``null`` / ``[]`` / ``123`` / ``"abc"`` 都是
7
+ **合法 JSON**。三处裸调 ``.get`` 的判据面因此全线逃逸 AttributeError(而 except
8
+ 元组只收 ValueError/OSError):
9
+
10
+ · ``mdcg.MdCG._load_index``(mdcg.py:1050 旧行)``d.get("schema")`` ⇒
11
+ **构造即失败**:常驻 MCP 服务的记忆面整个起不来(rc=1),一次性脚本
12
+ (review_cli 等)全崩;而盘上 ``.md`` 真源完好无损——丢的只是派生索引
13
+ 这一条腿。实测四形态全部 ``AttributeError: '<type>' object has no
14
+ attribute 'get'``。
15
+ · ``mdcg.MdCG.compact_index``(mdcg.py:1235 旧行)同款 ⇒ 存活实例运行中
16
+ 快照被外部写坏一次,``close`` 自动 compact 的收尾路径就再也落不成快照。
17
+ · ``fsutil.ShardedLog.read_all``(fsutil.py:386 旧行)``r.get("_t", 0)`` ⇒
18
+ 分片日志里一行非对象记录即让**排序键**抛异常,索引装载与 compact 双断。
19
+
20
+ 深度 2 补充(N225 v1.0 报告 §0「已知缺口」点名的同族面——**行是 dict 不等于
21
+ 字段可用**,本次补强批收口):
22
+ · ① **排序键坏型**:``_t`` / ``_s`` 被写成 ``"abc"`` / ``[]`` / ``null`` /
23
+ ``NaN`` ⇒ 旧键 ``(r.get("_t", 0), r.get("_s", 0))`` 在 sort 里抛
24
+ TypeError(``'<' not supported between instances of 'str' and 'float'``,
25
+ 实测见 F 组),而 except 面只有 ValueError/OSError ⇒ 与原始缺陷同一条
26
+ 断链。收口:单槽归一(数值且非 NaN 原样、其余记 0,与「缺键取 0」同口径),
27
+ 记录**不丢**,坏型槽进记账面(``note_bad_sortkey_rows``)。
28
+ · ② **载荷坏型**:分片记录 ``e`` 非 dict 且非 None(``[]`` / ``123`` /
29
+ ``"abc"``)⇒ 旧写法直接 ``nodes[nid] = e``,下游 ``_count_buckets`` 对
30
+ 元素裸调 ``.get`` 抛 AttributeError。收口:装载重放与 compact 重放**共用
31
+ 同一实现**(``MdCG._apply_log``)——坏载荷跳过 + 记账(与①同一记账面),
32
+ ``e is None`` 的 tombstone 语义一字不改;``_count_buckets`` **不加**类型闸
33
+ (防在上游,不新增第三套判据)。
34
+
35
+ 修复口径(有界,不新增第三套判据):
36
+ ① 装载面:顶层非对象与 ValueError/OSError **同一降级口径**——视同损坏 ⇒
37
+ 走 ``_load_index`` / ``compact_index`` **既有**的「回退全库扫描」路径
38
+ (宁重扫勿清池)。``_maybe_reload_index`` 经 ``_load_index`` 收口后不再
39
+ 抛,故未加宽其 except 面(A 组第 5 条钉这一点)。
40
+ ② 日志面:``read_all`` 逐行分流——dict 进回放,非对象行进记账面
41
+ (``note_nonobject_rows``:模块级计数 + 有界样本 + stderr 告警);
42
+ 排序键坏型同面记账(``note_bad_sortkey_rows``)。**容忍 ≠ 静默**。
43
+ ③ 不动 ``read_jsonl`` 的通用契约(其它消费面不在本次范围);不动 tombstone
44
+ 语义(``e is None`` 的删除重放保持原行为)。
45
+
46
+ 守卫构成(A 装载面 / B compact 面 / C 分片日志面 / D stdio 端到端 /
47
+ E 载荷坏型收口 / F 排序键健壮化 / G tombstone 语义 / H read_jsonl 通用面):
48
+ A 四形态 × 5 条(20):构造不抛 · 确有全库扫描 · 既有节点一个不丢 · 不得退化
49
+ 成空池 · 探活重载(``_maybe_reload_index``)不抛且节点仍全
50
+ B 四形态 × 4 条(16):compact 不抛 · 落盘快照恢复为可再装载 dict · 不清池
51
+ (账本对得上盘面)· 返回值含既有节点
52
+ C 9 条:read_all 不抛 / 只收 dict / 坏行计数可断言 / stderr 告警可观测 /
53
+ tombstone 记录保留在返回列表(e=None 不得当坏行丢掉)/ 排序不崩;重放面:
54
+ 构造不抛 / 合法新记录生效
55
+ D 5 条:非对象快照 + 坏分片的临时库上起 ``md_cg.mcp_server`` stdio 主循环 ⇒
56
+ rc=0 · initialize 应答 · tools/list 应答 · 非对象行仍回 -32600 · 攻击行之后
57
+ 仍应答(**不是 rc=1 / 进程即死**)
58
+ E 6 条(载荷坏型收口,契约②):装载不抛 / 坏载荷不进 nodes / 合法 upsert 仍
59
+ 生效 / 记账可断言且不静默 / compact 面不抛且快照无坏载荷 id / **两面对同一
60
+ 分片的 nodes 逐键相等**(同口径)
61
+ F 5 条(排序键健壮化,契约①):read_all 不抛**且**旧键直排在同一批记录上必抛
62
+ (fixture 有判别力)/ 合法记录与旧键 oracle **逐位一致(序与内容)** /
63
+ 记录一个不丢且按键单调不减 / 坏型键计数可断言 / 不静默
64
+ G 2 条(tombstone 语义):装载面 e is None ⇒ pop;compact 面同款(删除不复活)
65
+ H 2 条(read_jsonl 通用面):非对象行仍**原样产出**(类型序列逐位一致)· 产出
66
+ 对象与 json.loads 结果同值(不做类型改造)
67
+
68
+ **上一轮 E 组 5 条「源文本在场」断言已整组删除**(`'isinstance(d, dict) and
69
+ d.get("schema") == SCHEMA' in win_load` 这类源码文本级自证:整组换成空组后
70
+ ``--branch-baseline`` 仍 PASS,零判别力)。其判据面改由行为断言承担,一一对应:
71
+ 旧 E1(装载面类型闸在场)→ A 组(行为,变异①钉死)
72
+ 旧 E2(compact 面非对象分支在场)→ B 组(行为,变异②钉死)
73
+ 旧 E3(read_all 过滤与记账成对在场)→ C 组(行为,变异③钉死)
74
+ 旧 E4(read_jsonl 通用契约未改)→ H 组(行为,变异⑧钉死)
75
+ 旧 E5(tombstone 语义两处仍在)→ G 组(行为,变异⑤/⑦从两侧钉死)
76
+
77
+ ``--branch-baseline`` 定点变异自证(与 ``test_neg_condition_hits`` 同口径):
78
+ 逐个关掉一处判据,守卫**必须转红**且红项**恰好**落在该判据负责的组、数量等于
79
+ 该组断言数(多一项少一项都报红);锚点漂移报 ``ANCHOR-MISS`` 并以**退出码 2**
80
+ fail-closed 停手。基线源是**工作区源码**(``inspect.getsource`` 取自当前已加载
81
+ 模块),绝不绑 git HEAD——本仓批次 80/81 两次教训:基线绑提交那一刻即失效。
82
+
83
+ **组与判据一一对应**(这样「恰好等于该组断言数」才可成立):每组只放该判据
84
+ 自己的断言,fixture 只放该判据自己的病理(各组互不交叉),故每处变异只红一组
85
+ (装载面/日志面两处变异另红端到端 D 组——D 组的两个病理本就是「非对象快照」与
86
+ 「非对象行」)。变异表里同一组出现两次(G 组被⑧… 被⑤与⑦从两侧钉死)是刻意的:
87
+ 口径「过窄」与「过宽」都必须转红。
88
+
89
+ 隔离纪律(与 ``test_n206_stdio_jsonrpc_type`` / ``test_issue39_utf8_stdio`` 同款):
90
+ env 剔净全部 MDCG_*/HIVE_* 注入面,只注入指向临时目录的
91
+ ``MDCG_ROOT``/``MDCG_AUX_ROOT``/``MDCG_STATE_ROOT``/``MDCG_DATA_ROOT``/
92
+ ``MDCG_TENANT_REGISTRY``(指向不存在的临时路径=空表回落)/``MDCG_SUSTAIN=0``
93
+ (关常驻循环)/``MDCG_LEGACY_ENV_AUTH=1``(既有豁免面,零令牌、不回落真实
94
+ `~/.mdcg`);全部合成库落在 ``tempfile.mkdtemp`` 下,跑完 rmtree。**绝不触在役
95
+ 活库、真实令牌库与真实 serve。**
96
+
97
+ 运行:
98
+ python -X utf8 -m md_cg.test_n225_nonobject_load
99
+ python -X utf8 -m md_cg.test_n225_nonobject_load --branch-baseline
100
+ """
101
+ from __future__ import annotations
102
+
103
+ import contextlib
104
+ import inspect
105
+ import io
106
+ import json
107
+ import os
108
+ import shutil
109
+ import subprocess
110
+ import sys
111
+ import tempfile
112
+ import textwrap
113
+ from contextlib import redirect_stderr, redirect_stdout
114
+
115
+ try:
116
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
117
+ except AttributeError:
118
+ pass
119
+
120
+ from . import fsutil as F # noqa: E402
121
+ from . import mdcg as M # noqa: E402
122
+ from .mdcg import MdCG # noqa: E402
123
+
124
+ # 四种「合法 JSON 但非对象」形态(N225 的攻击面全谱)
125
+ BODIES = (("null", "null"), ("empty_array", "[]"),
126
+ ("bare_int", "123"), ("bare_str", '"abc"'))
127
+
128
+ PASS, FAIL = 0, 0
129
+ FAILS = []
130
+ _CURRENT = ["?"]
131
+ _GROUP_FAILS = {}
132
+ _TMP_ROOTS = []
133
+ _LIVE = []
134
+
135
+
136
+ # 生效条件:把 PASS/FAIL/失败名单/分组失败数全部清零并解除当前组名(每次完整跑套件前调用;--branch-baseline 每个变异轮都要一次干净基线)。
137
+ def _reset_counters():
138
+ global PASS, FAIL
139
+ PASS = FAIL = 0
140
+ del FAILS[:]
141
+ _GROUP_FAILS.clear()
142
+ _CURRENT[0] = "?"
143
+
144
+
145
+ # 生效条件:group 为非空字符串时设为当前组名并在 _GROUP_FAILS 里开出该组条目(未开则记 0);重复调用同名组只是切换当前组,不清零已有计数。
146
+ def begin(group: str):
147
+ _CURRENT[0] = group
148
+ _GROUP_FAILS.setdefault(group, 0)
149
+
150
+
151
+ # 生效条件:cond 为真记一条通过并打印;为假把 FAIL 与当前组失败数各 +1、把 name 追加进 FAILS 并打印 detail(detail 为空则只打印名字)。
152
+ def check(name, cond, detail=""):
153
+ global PASS, FAIL
154
+ if cond:
155
+ PASS += 1
156
+ print(" [ok] %s" % name)
157
+ return True
158
+ FAIL += 1
159
+ _GROUP_FAILS[_CURRENT[0]] = _GROUP_FAILS.get(_CURRENT[0], 0) + 1
160
+ FAILS.append(name)
161
+ print(" [FAIL] %s %s" % (name, detail))
162
+ return False
163
+
164
+
165
+ # 生效条件:fn 为无参回调时调用它取条件值——回调抛异常即判红并把异常类型与消息写进 detail(**不中断整组**,保证「变异下红项数 == 该组断言数」这一精确口径成立);fn 正常返回则按返回值真假记账;detail 传 callable 时在同一保护下求值(detail 自身抛异常也只判红、不外逸)。
166
+ def check_fn(name, fn, detail=""):
167
+ if callable(detail):
168
+ try:
169
+ detail = detail()
170
+ except Exception as exc: # noqa: BLE001
171
+ detail = "detail 求值抛 %s: %s" % (type(exc).__name__, exc)
172
+ try:
173
+ cond = bool(fn())
174
+ extra = detail
175
+ except Exception as exc: # noqa: BLE001 —— 红态正是抛异常
176
+ cond = False
177
+ extra = "回调抛 %s: %s(%s)" % (type(exc).__name__, exc, detail)
178
+ return check(name, cond, extra)
179
+
180
+
181
+ # 生效条件:新建系统临时目录(prefix 前缀)并把路径登记进 _TMP_ROOTS,供本次运行收尾统一 rmtree;返回该路径。
182
+ def _tmpdir(prefix: str) -> str:
183
+ p = tempfile.mkdtemp(prefix=prefix)
184
+ _TMP_ROOTS.append(p)
185
+ return p
186
+
187
+
188
+ # 生效条件:无入参,对 _TMP_ROOTS 里每个目录 rmtree(ignore_errors=True) 并清空清单、对 _LIVE 里每个认知图实例尝试 close()(异常吞掉,收尾不该再抛);收尾是幂等的。
189
+ def _cleanup():
190
+ for cg in _LIVE:
191
+ try:
192
+ cg.close()
193
+ except Exception: # noqa: BLE001 —— 收尾路径不再抛
194
+ pass
195
+ del _LIVE[:]
196
+ for p in _TMP_ROOTS:
197
+ shutil.rmtree(p, ignore_errors=True)
198
+ del _TMP_ROOTS[:]
199
+
200
+
201
+ # 生效条件:root 为库根时构造 MdCG(root, autoflush=1)、按 ids 逐条 add 最小节点(layer=knowledge)后 close()(close 落合法快照,使盘面 .md 与快照同源);返回 root。
202
+ def _build_lib(root: str, ids=("n1", "n2")):
203
+ cg = MdCG(root, autoflush=1)
204
+ for nid in ids:
205
+ cg.add(nid, "# 功能名:节点%s\n# 正文:合成节点 %s" % (nid, nid),
206
+ layer="knowledge")
207
+ cg.close()
208
+ return root
209
+
210
+
211
+ # 生效条件:root 下 _index_log/ 已建成目录时,写入一份固定的坏分片 9000-c0ffee00.log——entry 非 None 时首行是一条合法 id=c_new 记录,随后固定四行非对象(null/[]/123/"abc"),末行是 id=n2 的 tombstone(e=None);返回该分片路径。
212
+ def _write_bad_shard(root: str, entry=None) -> str:
213
+ d = os.path.join(root, "_index_log")
214
+ os.makedirs(d, exist_ok=True)
215
+ p = os.path.join(d, "9000-c0ffee00.log")
216
+ rows = []
217
+ if entry is not None:
218
+ rows.append(json.dumps({"id": "c_new", "e": entry,
219
+ "_t": 1.0, "_s": 1}, ensure_ascii=False))
220
+ rows += ["null", "[]", "123", '"abc"']
221
+ rows.append(json.dumps({"id": "n2", "e": None, "_t": 2.0, "_s": 2},
222
+ ensure_ascii=False))
223
+ with open(p, "w", encoding="utf-8") as f:
224
+ f.write("\n".join(rows) + "\n")
225
+ return p
226
+
227
+
228
+ # 生效条件:root 下 _index_log/ 已建成目录时,写入坏**载荷**分片 9001-bad0payl.log——三行 e 非 dict 且非 None([]/123/"abc",id 为 p_arr/p_int/p_str)+ 一行合法 upsert(id=n1,载荷带 bucket=n225pay,用于证明合法记录未被坏载荷牵连);该分片**不含**非对象行、不含 e is None 行(组间隔离:另两类病理归 C/G 组);返回该分片路径。
229
+ def _write_payload_shard(root: str) -> str:
230
+ d = os.path.join(root, "_index_log")
231
+ os.makedirs(d, exist_ok=True)
232
+ p = os.path.join(d, "9001-bad0payl.log")
233
+ rows = [
234
+ json.dumps({"id": "p_arr", "e": [], "_t": 1.0, "_s": 1},
235
+ ensure_ascii=False),
236
+ json.dumps({"id": "p_int", "e": 123, "_t": 2.0, "_s": 2},
237
+ ensure_ascii=False),
238
+ json.dumps({"id": "p_str", "e": "abc", "_t": 3.0, "_s": 3},
239
+ ensure_ascii=False),
240
+ json.dumps({"id": "n1", "e": {"path": "knowledge/n1.md",
241
+ "layer": "knowledge",
242
+ "bucket": "n225pay"},
243
+ "_t": 4.0, "_s": 4}, ensure_ascii=False),
244
+ ]
245
+ with open(p, "w", encoding="utf-8") as f:
246
+ f.write("\n".join(rows) + "\n")
247
+ return p
248
+
249
+
250
+ # 生效条件:root 下 _index_log/ 已建成目录时,写入 tombstone 分片 9002-tombstn.log——单行 {"id":"n2","e":None,_t=1.0,_s=1}(删除记录);该分片不含非对象行、不含坏载荷行(组间隔离:另两类病理归 C/E 组);返回该分片路径。
251
+ def _write_tombstone_shard(root: str) -> str:
252
+ d = os.path.join(root, "_index_log")
253
+ os.makedirs(d, exist_ok=True)
254
+ p = os.path.join(d, "9002-tombstn.log")
255
+ with open(p, "w", encoding="utf-8") as f:
256
+ f.write(json.dumps({"id": "n2", "e": None, "_t": 1.0, "_s": 1},
257
+ ensure_ascii=False) + "\n")
258
+ return p
259
+
260
+
261
+ # 生效条件:path 指向快照/任意文本文件时以 utf-8 覆盖写入 body(非对象快照的注入点);父目录不存在会抛 OSError。
262
+ def _overwrite(path: str, body: str):
263
+ with open(path, "w", encoding="utf-8") as f:
264
+ f.write(body)
265
+
266
+
267
+ # 生效条件:body 为四形态之一的文本时,建库 → 覆盖快照为 body → 带 _scan_nodes 计数桩重开 MdCG,返回上下文 dict(root / cg / scans / error);任何一步抛异常都收进 ctx["error"] 而不外抛(保证后续逐条断言各自判红)。
268
+ def _case_load(body: str) -> dict:
269
+ root = _tmpdir("mdcg_n225_load_")
270
+ ctx = {"root": root, "cg": None, "scans": [0], "error": None}
271
+ try:
272
+ _build_lib(root)
273
+ _overwrite(os.path.join(root, "_index.json"), body)
274
+ orig = MdCG._scan_nodes
275
+
276
+ def counted(self): # noqa: ANN001 —— 计数桩
277
+ ctx["scans"][0] += 1
278
+ return orig(self)
279
+
280
+ MdCG._scan_nodes = counted
281
+ try:
282
+ ctx["cg"] = MdCG(root, autoflush=1)
283
+ finally:
284
+ MdCG._scan_nodes = orig
285
+ _LIVE.append(ctx["cg"])
286
+ except Exception as exc: # noqa: BLE001
287
+ ctx["error"] = exc
288
+ return ctx
289
+
290
+
291
+ # 生效条件:body 为四形态之一的文本时,建库 → 在**合法快照**上构造存活实例(保证构造面不参与本组)→ 把快照覆盖为 body(模拟运行中被外部写坏)→ 调 compact_index(),返回上下文 dict(root / cg / surf / idx / error);异常收进 ctx["error"]。
292
+ def _case_compact(body: str) -> dict:
293
+ root = _tmpdir("mdcg_n225_compact_")
294
+ ctx = {"root": root, "cg": None, "surf": None, "idx": None,
295
+ "error": None}
296
+ try:
297
+ _build_lib(root)
298
+ cg = MdCG(root, autoflush=1)
299
+ ctx["cg"] = cg
300
+ _LIVE.append(cg)
301
+ ctx["surf"] = cg._count_md_files()
302
+ _overwrite(os.path.join(root, "_index.json"), body)
303
+ ctx["idx"] = cg.compact_index()
304
+ except Exception as exc: # noqa: BLE001
305
+ ctx["error"] = exc
306
+ return ctx
307
+
308
+
309
+ # 生效条件:无入参时建库 → 写坏分片(含合法 c_new 记录 + 四行非对象 + n2 的 tombstone)→ 先重置记账、以 redirect_stderr 捕获 read_all 的告警输出直接调 ShardedLog.read_all,再重开 MdCG 走索引重放;返回上下文 dict(root / recs / stats / stderr / cg / nodes / stats_after / 各阶段 error)。
310
+ def _case_shard() -> dict:
311
+ root = _tmpdir("mdcg_n225_shard_")
312
+ ctx = {"root": root, "recs": None, "stats": None, "stderr": "",
313
+ "cg": None, "nodes": None, "stats_after": None,
314
+ "error": None, "error_replay": None, "error0": None}
315
+ try:
316
+ _build_lib(root)
317
+ entry = {"path": "knowledge/c_new.md", "layer": "knowledge",
318
+ "bucket": "n225"}
319
+ _write_bad_shard(root, entry)
320
+ # ---- ① 分片日志读面(不构造 MdCG,与装载面解耦)----
321
+ F.reset_nonobject_row_stats()
322
+ buf = io.StringIO()
323
+ try:
324
+ with redirect_stderr(buf):
325
+ ctx["recs"] = F.ShardedLog.read_all(
326
+ os.path.join(root, "_index_log"))
327
+ except Exception as exc: # noqa: BLE001
328
+ ctx["error"] = exc
329
+ ctx["stderr"] = buf.getvalue()
330
+ ctx["stats"] = F.nonobject_row_stats()
331
+ # ---- ② 索引重放面(合法快照 + 坏分片)----
332
+ try:
333
+ cg = MdCG(root, autoflush=1)
334
+ ctx["cg"] = cg
335
+ _LIVE.append(cg)
336
+ ctx["nodes"] = set(cg.index["nodes"])
337
+ except Exception as exc: # noqa: BLE001
338
+ ctx["error_replay"] = exc
339
+ ctx["stats_after"] = F.nonobject_row_stats()
340
+ except Exception as exc: # noqa: BLE001
341
+ ctx["error0"] = exc
342
+ return ctx
343
+
344
+
345
+ # 生效条件:无入参时建库 → 写坏**载荷**分片 → 重置记账 → 构造 MdCG(合法快照基底 + 坏载荷重放)→ 再调同一个实例的 compact_index()(同一批坏载荷走第二次重放);返回上下文 dict(root / cg / nodes / nodes_after_compact / payload_before / payload_after / stderr / err / err_compact / idx)。
346
+ def _case_payload() -> dict:
347
+ root = _tmpdir("mdcg_n225_payload_")
348
+ ctx = {"root": root, "cg": None, "nodes": None, "idx": None,
349
+ "payload_before": None, "payload_after": None, "total_after": None,
350
+ "stderr": "", "err": None, "err_compact": None, "err0": None}
351
+ try:
352
+ _build_lib(root)
353
+ _write_payload_shard(root)
354
+ F.reset_nonobject_row_stats()
355
+ buf = io.StringIO()
356
+ try:
357
+ with redirect_stderr(buf):
358
+ cg = MdCG(root, autoflush=1)
359
+ ctx["cg"] = cg
360
+ _LIVE.append(cg)
361
+ ctx["nodes"] = cg.index["nodes"]
362
+ ctx["payload_before"] = F.bad_payload_row_stats()[0]
363
+ try:
364
+ ctx["idx"] = cg.compact_index()
365
+ except Exception as exc: # noqa: BLE001
366
+ ctx["err_compact"] = exc
367
+ ctx["payload_after"] = F.bad_payload_row_stats()[0]
368
+ ctx["total_after"] = F.nonobject_row_stats()[0]
369
+ except Exception as exc: # noqa: BLE001
370
+ ctx["err"] = exc
371
+ ctx["stderr"] = buf.getvalue()
372
+ except Exception as exc: # noqa: BLE001
373
+ ctx["err0"] = exc
374
+ return ctx
375
+
376
+
377
+ # 生效条件:legal(v) 为真当且仅当 v 为 int/float 且 v == v(非 NaN)——即排序键的「合法槽」口径。
378
+ def _legal_slot(v) -> bool:
379
+ return isinstance(v, (int, float)) and v == v
380
+
381
+
382
+ # 生效条件:r 为记录时,返回其排序键「坏型槽记 0、合法槽原样」二元组——与实现口径同源的断言侧表达(F3 的单调性判据用;合法记录的**次序证明**不靠它,靠 F2 的旧键 oracle 逐位对照)。
383
+ def _norm_key(r):
384
+ return tuple(v if _legal_slot(v) else 0
385
+ for v in (r.get("_t", 0), r.get("_s", 0)))
386
+
387
+
388
+ # 生效条件:rows 为记录列表时,取其中 _t 与 _s 两槽都合法的记录,按**改前旧键** (r.get("_t", 0), r.get("_s", 0)) 排序后返回——改前行为的独立 oracle(不调用被测实现,只用 Python 内建 sorted)。
389
+ def _oracle_legal(rows):
390
+ keep = [r for r in rows
391
+ if _legal_slot(r.get("_t", 0)) and _legal_slot(r.get("_s", 0))]
392
+ return sorted(keep, key=lambda r: (r.get("_t", 0), r.get("_s", 0)))
393
+
394
+
395
+ # 生效条件:d 为分片目录时,按 read_all 的收集序(分片名序 + 文件行序)经 F.read_jsonl 取全部 dict 行返回——oracle 的输入面(不调用 read_all)。
396
+ def _collect_rows(d: str):
397
+ out = []
398
+ for fn in sorted(os.listdir(d)):
399
+ if not fn.endswith(".log"):
400
+ continue
401
+ for rec in F.read_jsonl(os.path.join(d, fn)):
402
+ if isinstance(rec, dict):
403
+ out.append(rec)
404
+ return out
405
+
406
+
407
+ # 生效条件:无入参时在临时目录下用**生产写点**(ShardedLog.append,两个写者实例=两个分片,含并列 _t、跨写者重叠 _s、乱序写入)造真实分片记录,再手写一份坏型排序键分片(str/list/None/NaN 四形态 + 一行缺 _t 的合法行)→ 重置记账 → 以 redirect_stderr 捕获告警后调 read_all;返回上下文 dict(root / d / rows / oracle / old_raises / recs / got_legal / sortkey_before / stderr / err)。
408
+ def _case_sortkey() -> dict:
409
+ root = _tmpdir("mdcg_n225_sortkey_")
410
+ ctx = {"root": root, "d": os.path.join(root, "shards"), "rows": None,
411
+ "oracle": None, "old_raises": None, "recs": None,
412
+ "got_legal": None, "sortkey_before": None, "stderr": "",
413
+ "err": None, "err0": None}
414
+ try:
415
+ d = ctx["d"]
416
+ os.makedirs(d, exist_ok=True)
417
+ # 写者 A / B(各占一个分片):真实写点产出的记录(_t 为 time.time 形态、
418
+ # _s 为分片内序号,两个写者的 _s 故意重叠 ⇒ 跨分片合并必须靠 (t, s) 定序)
419
+ lg_a = F.ShardedLog(d)
420
+ for t, s, nid in ((1000.0, 1, "a"), (1000.0, 2, "b"), (999.0, 3, "c"),
421
+ (1000.0, 4, "d"), (1002.0, 5, "e")):
422
+ lg_a.append({"id": nid, "e": {"path": "knowledge/%s.md" % nid,
423
+ "bucket": "k"}, "_t": t, "_s": s})
424
+ lg_a.close()
425
+ lg_b = F.ShardedLog(d)
426
+ for t, s, nid in ((1000.0, 1, "f"), (997.5, 2, "g"), (1000.0, 3, "h")):
427
+ lg_b.append({"id": nid, "e": {"path": "knowledge/%s.md" % nid,
428
+ "bucket": "k"}, "_t": t, "_s": s})
429
+ lg_b.close()
430
+ rows = [
431
+ {"id": "s1", "e": {"path": "knowledge/s1.md"}, "_t": "abc",
432
+ "_s": 9}, # str 槽
433
+ {"id": "s2", "e": {"path": "knowledge/s2.md"}, "_t": 5.0,
434
+ "_s": []}, # list 槽
435
+ {"id": "s3", "e": {"path": "knowledge/s3.md"}, "_s": 11}, # 缺 _t(合法)
436
+ {"id": "s4", "e": {"path": "knowledge/s4.md"}, "_t": None,
437
+ "_s": None}, # None 槽
438
+ {"id": "s5", "e": {"path": "knowledge/s5.md"},
439
+ "_t": float("nan"), "_s": 1}, # NaN 槽
440
+ ]
441
+ with open(os.path.join(d, "9999-badkey.log"), "w",
442
+ encoding="utf-8") as f:
443
+ f.write("\n".join(json.dumps(r, ensure_ascii=False)
444
+ for r in rows) + "\n")
445
+ ctx["rows"] = _collect_rows(d)
446
+ try:
447
+ sorted(ctx["rows"], key=lambda r: (r.get("_t", 0), r.get("_s", 0)))
448
+ ctx["old_raises"] = False
449
+ except TypeError as exc:
450
+ ctx["old_raises"] = "%s: %s" % (type(exc).__name__, exc)
451
+ ctx["oracle"] = _oracle_legal(ctx["rows"])
452
+ F.reset_nonobject_row_stats()
453
+ buf = io.StringIO()
454
+ try:
455
+ with redirect_stderr(buf):
456
+ ctx["recs"] = F.ShardedLog.read_all(d)
457
+ except Exception as exc: # noqa: BLE001
458
+ ctx["err"] = exc
459
+ ctx["stderr"] = buf.getvalue()
460
+ if isinstance(ctx["recs"], list):
461
+ ctx["got_legal"] = [r for r in ctx["recs"]
462
+ if _legal_slot(r.get("_t", 0))
463
+ and _legal_slot(r.get("_s", 0))]
464
+ ctx["sortkey_before"] = F.bad_sortkey_row_stats()[0]
465
+ except Exception as exc: # noqa: BLE001
466
+ ctx["err0"] = exc
467
+ return ctx
468
+
469
+
470
+ # 生效条件:无入参时建库 → 写 tombstone 分片(n2 的删除记录)→ 构造 MdCG(合法快照基底 + tombstone 重放)→ 再调同一实例的 compact_index();返回上下文 dict(root / cg / nodes / idx / err / err_compact / err0)。
471
+ def _case_tombstone() -> dict:
472
+ root = _tmpdir("mdcg_n225_tomb_")
473
+ ctx = {"root": root, "cg": None, "nodes": None, "idx": None,
474
+ "err": None, "err_compact": None, "err0": None}
475
+ try:
476
+ _build_lib(root)
477
+ _write_tombstone_shard(root)
478
+ cg = MdCG(root, autoflush=1)
479
+ ctx["cg"] = cg
480
+ _LIVE.append(cg)
481
+ ctx["nodes"] = set(cg.index["nodes"])
482
+ try:
483
+ ctx["idx"] = cg.compact_index()
484
+ except Exception as exc: # noqa: BLE001
485
+ ctx["err_compact"] = exc
486
+ except Exception as exc: # noqa: BLE001
487
+ ctx["err"] = exc
488
+ return ctx
489
+
490
+
491
+ # 生效条件:无入参时在临时目录写一份 jsonl(两条合法 dict 行 + 四行非对象行,非对象行夹在中间),直接调 F.read_jsonl 取产出序列;返回上下文 dict(path / got / error)。
492
+ def _case_jsonl() -> dict:
493
+ root = _tmpdir("mdcg_n225_jsonl_")
494
+ p = os.path.join(root, "x.jsonl")
495
+ with open(p, "w", encoding="utf-8") as f:
496
+ f.write('{"a":1}\nnull\n[]\n123\n"abc"\n{"a":2}\n')
497
+ ctx = {"path": p, "got": None, "error": None}
498
+ try:
499
+ ctx["got"] = list(F.read_jsonl(p))
500
+ except Exception as exc: # noqa: BLE001
501
+ ctx["error"] = exc
502
+ return ctx
503
+
504
+
505
+ # 模拟宿主前先把「身份/路径/编码」注入面清干净——子进程只吃临时目录(与 N206 同款)
506
+ _DIRTY_KEYS = (
507
+ "MDCG_TOKEN", "MDCG_TOKEN_FILE", "MDCG_MASTER_KEY", "MDCG_CLEARANCE",
508
+ "MDCG_TENANT", "MDCG_TENANT_REGISTRY",
509
+ "MDCG_CAN_ADMIN", "MDCG_CAN_WRITE", "MDCG_LEGACY_ENV_AUTH",
510
+ "MDCG_LEGACY_ENV_ADMIN", "MDCG_SESSION", "DSH_SESSION_ID",
511
+ "MDCG_HARNESS", "MDCG_UNIT", "MDCG_ROOT", "MDCG_STATE_ROOT",
512
+ "MDCG_DATA_ROOT", "MDCG_AUX_ROOT", "MDCG_SUSTAIN", "MDCG_SUSTAIN_NAME",
513
+ "MDCG_MCP_SURFACE", "MDCG_TOOL_FACE", "MDCG_ACTOR",
514
+ "MDCG_VERIFIER_MODULES", "MDCG_HIVE_JOBS",
515
+ "PYTHONUTF8", "PYTHONIOENCODING", "PYTHONLEGACYWINDOWSSTDIO",
516
+ )
517
+
518
+
519
+ # 生效条件:tmp 为本次隔离根时,返回剔净 _DIRTY_KEYS 后的 os.environ 副本再注入九个指向 tmp 的覆盖键(MDCG_ROOT / MDCG_TENANT_REGISTRY / MDCG_STATE_ROOT / MDCG_DATA_ROOT / MDCG_AUX_ROOT / MDCG_SUSTAIN / MDCG_LEGACY_ENV_AUTH / MDCG_ACTOR,外加指向临时路径的 MDCG_TOKEN_FILE 与哑主密钥 MDCG_MASTER_KEY)——子进程绝不读真实 ~/.mdcg、真实令牌库与真实密钥面。
520
+ def _env(tmp: str) -> dict:
521
+ env = {k: v for k, v in os.environ.items() if k not in _DIRTY_KEYS}
522
+ env["MDCG_ROOT"] = os.path.join(tmp, "cgroot")
523
+ # 登记表指向**不存在**的临时路径 → 空表回落,绝不读真实 ~/.mdcg
524
+ env["MDCG_TENANT_REGISTRY"] = os.path.join(tmp, "_tenants_absent.json")
525
+ env["MDCG_STATE_ROOT"] = os.path.join(tmp, "state")
526
+ env["MDCG_DATA_ROOT"] = os.path.join(tmp, "data")
527
+ # 子目录名不可取 "aux"(Windows 保留设备名,见 test_issue39 头注)
528
+ env["MDCG_AUX_ROOT"] = os.path.join(tmp, "auxroot")
529
+ env["MDCG_TOKEN_FILE"] = os.path.join(tmp, "tokens.json") # 临时令牌库
530
+ env["MDCG_MASTER_KEY"] = "00" * 32 # 哑主密钥
531
+ env["MDCG_SUSTAIN"] = "0" # 关常驻循环(线程面归零)
532
+ env["MDCG_LEGACY_ENV_AUTH"] = "1" # 既有豁免面:零令牌、无二次开关
533
+ env["MDCG_ACTOR"] = "n225-guard"
534
+ return env
535
+
536
+
537
+ # 当前生效的变异(仅 --branch-baseline 期间非 None):内存 patch 不跨进程,
538
+ # D 组走独立子进程 ⇒ 靠 sitecustomize 把同一处定点变异注入子进程,端到端面
539
+ # 才有真正的判别力。注入以 stderr 标记自证,标记缺失即 D 组判红(fail-closed)。
540
+ _MUTATION = [None]
541
+
542
+ _SITECUSTOMIZE_SRC = '''# N225 守卫跨进程变异注入(临时件,随 tmp 目录一并回收)
543
+ import os
544
+ import sys
545
+ import textwrap
546
+ import inspect
547
+ _repo = os.environ.get("N225_REPO") or ""
548
+ if _repo and _repo not in sys.path:
549
+ sys.path.insert(0, _repo)
550
+ try:
551
+ from md_cg import mdcg as _M, fsutil as _F
552
+ _which = os.environ["N225_MUT_WHICH"]
553
+ _fname = os.environ["N225_MUT_FUNC"]
554
+ _old = os.environ["N225_MUT_OLD"]
555
+ _new = os.environ["N225_MUT_NEW"]
556
+ if _which == "mdcg":
557
+ _holder, _raw = _M.MdCG, _M.MdCG.__dict__[_fname]
558
+ elif _which == "fsutil":
559
+ _holder, _raw = _F.ShardedLog, _F.ShardedLog.__dict__[_fname]
560
+ else: # fsutil_mod:模块级函数
561
+ _holder, _raw = _F, _F.__dict__[_fname]
562
+ _src = inspect.getsource(getattr(_raw, "__func__", _raw))
563
+ _src = textwrap.dedent(_src[_src.index("def %s(" % _fname):])
564
+ _ns = dict(vars(_M if _which == "mdcg" else _F))
565
+ _ns.setdefault("__name__", "n225_mut")
566
+ exec(compile(_src.replace(_old, _new), "n225_mut.py", "exec"), _ns)
567
+ if _which == "fsutil":
568
+ setattr(_holder, _fname, staticmethod(_ns[_fname]))
569
+ else:
570
+ setattr(_holder, _fname, _ns[_fname])
571
+ sys.stderr.write("[n225-mut] injected %s.%s\\n" % (_which, _fname))
572
+ except Exception as _e:
573
+ sys.stderr.write("[n225-mut] inject FAILED: %r\\n" % (_e,))
574
+ '''
575
+
576
+
577
+ # 生效条件:lines 为待喂给 stdin 的字符串序列、tmp 为隔离根时,spawn `python -X utf8 -m md_cg.mcp_server`(cwd=仓根、env=_env(tmp))一次喂完并 communicate;超时则 kill 后取残余输出;随后删掉**本子进程自己**的自报戳;返回 (returncode, stdout, stderr)。
578
+ def _talk(lines, tmp: str, timeout: int = 180):
579
+ env = _env(tmp)
580
+ mut = _MUTATION[0]
581
+ if mut is not None:
582
+ # 跨进程注入:sitecustomize 是 Python 启动时自动 import 的官方钩子,
583
+ # 放到 PYTHONPATH 上即被本子进程吃到——生产启动形态(-m)保持不变。
584
+ sc_dir = os.path.join(tmp, "_mutpath")
585
+ os.makedirs(sc_dir, exist_ok=True)
586
+ with open(os.path.join(sc_dir, "sitecustomize.py"), "w",
587
+ encoding="utf-8") as f:
588
+ f.write(_SITECUSTOMIZE_SRC)
589
+ env["PYTHONPATH"] = sc_dir + os.pathsep + env.get("PYTHONPATH", "")
590
+ env["N225_REPO"] = os.path.dirname(os.path.dirname(
591
+ os.path.abspath(__file__)))
592
+ env["N225_MUT_WHICH"] = mut["which"]
593
+ env["N225_MUT_FUNC"] = mut["func"]
594
+ env["N225_MUT_OLD"] = mut["old"]
595
+ env["N225_MUT_NEW"] = mut["new"]
596
+ proc = subprocess.Popen(
597
+ [sys.executable, "-X", "utf8", "-m", "md_cg.mcp_server"],
598
+ cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
599
+ env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
600
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
601
+ try:
602
+ out, err = proc.communicate("\n".join(lines) + "\n", timeout=timeout)
603
+ except subprocess.TimeoutExpired:
604
+ proc.kill()
605
+ out, err = proc.communicate()
606
+ try:
607
+ os.remove(os.path.join(tempfile.gettempdir(), "md_cg_servers",
608
+ "%d.json" % proc.pid))
609
+ except OSError:
610
+ pass
611
+ return proc.returncode, out or "", err or ""
612
+
613
+
614
+ # 生效条件:rid/method 给定、params 为 None 时省略该键,否则原样写入;返回一行紧凑 JSON-RPC 请求文本。
615
+ def _rpc(rid, method, params=None) -> str:
616
+ msg = {"jsonrpc": "2.0", "id": rid, "method": method}
617
+ if params is not None:
618
+ msg["params"] = params
619
+ return json.dumps(msg, ensure_ascii=False)
620
+
621
+
622
+ # 生效条件:out 为 stdout 文本时逐行 strip、空行跳过,返回 [(原始行, 解析结果或 None)](解析失败记 None,供「全行合法」类断言消费)。
623
+ def _resp_lines(out: str):
624
+ got = []
625
+ for ln in out.splitlines():
626
+ ln = ln.strip()
627
+ if not ln:
628
+ continue
629
+ try:
630
+ got.append((ln, json.loads(ln)))
631
+ except ValueError:
632
+ got.append((ln, None))
633
+ return got
634
+
635
+
636
+ # 生效条件:obj 为 dict 时返回其 error.code,否则返回 None。
637
+ def _err_code(obj):
638
+ return (obj.get("error") or {}).get("code") if isinstance(obj, dict) else None
639
+
640
+
641
+ # 生效条件:端到端场景——建库 → 把快照覆盖为非对象 `[]` → 写坏分片 → 起 stdio 主循环喂 [initialize, tools/list, "[]", tools/list, shutdown],返回上下文 dict(tmp / rc / out / err / objs / by_id / error)。
642
+ def _case_stdio() -> dict:
643
+ tmp = _tmpdir("mdcg_n225_stdio_")
644
+ ctx = {"tmp": tmp, "rc": None, "out": "", "err": "", "objs": [],
645
+ "by_id": {}, "error": None, "inject_ok": True}
646
+ try:
647
+ root = os.path.join(tmp, "cgroot")
648
+ _build_lib(root)
649
+ _overwrite(os.path.join(root, "_index.json"), "[]")
650
+ _write_bad_shard(root, {"path": "knowledge/c_new.md",
651
+ "layer": "knowledge", "bucket": "n225"})
652
+ lines = [_rpc(1, "initialize"), _rpc(2, "tools/list"),
653
+ "[]", _rpc(3, "tools/list"), _rpc(4, "shutdown")]
654
+ ctx["rc"], ctx["out"], ctx["err"] = _talk(lines, tmp)
655
+ # 注入自证:变异轮里子进程必须报告「已注入」,否则 D 组判红(fail-closed,
656
+ # 避免「注入失效」被误读成「端到端面无判别力」)。
657
+ ctx["inject_ok"] = ("[n225-mut] injected" in ctx["err"]
658
+ if _MUTATION[0] is not None else True)
659
+ objs = [o for _raw, o in _resp_lines(ctx["out"])]
660
+ ctx["objs"] = objs
661
+ ctx["by_id"] = {o.get("id"): o for o in objs if isinstance(o, dict)}
662
+ except Exception as exc: # noqa: BLE001
663
+ ctx["error"] = exc
664
+ return ctx
665
+
666
+
667
+ # ---------------- A 装载面:四种非对象形态 ----------------
668
+
669
+ # 生效条件:逐形态跑 A 组 5 条断言(构造不抛 / 确有全库扫描 / 既有节点一个不丢 / 不退化成空池 / 探活重载不抛且节点仍全);ctx 由 _case_load 产出,构造失败时五条各自判红。
670
+ def g_a():
671
+ begin("A")
672
+ for tag, body in BODIES:
673
+ print("[A·%s] %s" % (tag, json.dumps(body)))
674
+ ctx = _case_load(body)
675
+ cg = ctx["cg"]
676
+ check_fn("A·%s·构造不抛(非对象快照视同损坏,走既有降级路径)" % tag,
677
+ lambda c=ctx: c["error"] is None,
678
+ "err=%r" % (ctx["error"],))
679
+ check_fn("A·%s·确有全库扫描(_scan_nodes 被调用,非快照路径)" % tag,
680
+ lambda c=ctx: c["scans"][0] >= 1,
681
+ "scans=%d" % ctx["scans"][0])
682
+ check_fn("A·%s·既有节点一个不丢({n1,n2} ⊆ nodes)" % tag,
683
+ lambda c=cg: {"n1", "n2"} <= set(c.index["nodes"]),
684
+ "nodes=%s" % (sorted(cg.index["nodes"]) if cg else None))
685
+ check_fn("A·%s·不得退化成空池(len(nodes) 与盘面 .md 数一致且 ≥2)"
686
+ % tag,
687
+ lambda c=cg: (c is not None
688
+ and len(c.index["nodes"]) == c._count_md_files()
689
+ and len(c.index["nodes"]) >= 2),
690
+ "nodes=%s surf=%s" % (
691
+ len(cg.index["nodes"]) if cg else None,
692
+ cg._count_md_files() if cg else None))
693
+
694
+ def _probe(c=cg, t=tag):
695
+ # 存活实例 + 外部再写一次非对象快照(签名变化)→ 探活必须重载成功。
696
+ # 这一条钉住「_maybe_reload_index 不得因非对象而抛」。
697
+ other = [b for n, b in BODIES if n != t][0]
698
+ if c is None:
699
+ return False
700
+ _overwrite(os.path.join(c.root, "_index.json"), other)
701
+ fired = c._maybe_reload_index()
702
+ return fired is True and {"n1", "n2"} <= set(c.index["nodes"])
703
+
704
+ check_fn("A·%s·探活重载不抛且节点仍全(_maybe_reload_index 收口)"
705
+ % tag, _probe)
706
+ try:
707
+ if cg is not None:
708
+ cg.close()
709
+ except Exception: # noqa: BLE001
710
+ pass
711
+
712
+
713
+ # ---------------- B compact 面:四种非对象形态 ----------------
714
+
715
+ # 生效条件:逐形态跑 B 组 4 条断言(compact 不抛 / 落盘快照恢复为可再装载 dict / 不清池,账本对得上盘面 / 返回值含既有节点);ctx 由 _case_compact 产出。
716
+ def g_b():
717
+ begin("B")
718
+ for tag, body in BODIES:
719
+ print("[B·%s] %s" % (tag, json.dumps(body)))
720
+ ctx = _case_compact(body)
721
+ snap_path = os.path.join(ctx["root"], "_index.json")
722
+
723
+ def _snap():
724
+ with open(snap_path, encoding="utf-8") as f:
725
+ return json.load(f)
726
+
727
+ check_fn("B·%s·compact_index 不抛(非对象视同损坏走 scan_fallback)"
728
+ % tag, lambda c=ctx: c["error"] is None,
729
+ "err=%r" % (ctx["error"],))
730
+ check_fn("B·%s·落盘快照恢复为可再装载 dict(schema 正确)" % tag,
731
+ lambda: isinstance(_snap(), dict)
732
+ and _snap().get("schema") == M.SCHEMA)
733
+ check_fn("B·%s·不清池(快照 nodes 数 == 盘面 .md 数)" % tag,
734
+ lambda: len((_snap().get("nodes") or {})) == ctx["surf"]
735
+ and ctx["surf"] >= 2,
736
+ lambda: "snap_nodes=%s surf=%s" % (
737
+ len((_snap().get("nodes") or {}))
738
+ if isinstance(_snap(), dict) else None, ctx["surf"]))
739
+ check_fn("B·%s·compact 返回值含既有节点" % tag,
740
+ lambda c=ctx: isinstance(c["idx"], dict)
741
+ and {"n1", "n2"} <= set(c["idx"].get("nodes") or {}))
742
+
743
+
744
+ # ---------------- C 分片日志面:非对象行 ----------------
745
+
746
+ # 生效条件:跑 C 组 9 条断言(read_all 不抛 / 只返回 dict 且条数正确 / 逐条是 dict / 坏行计数可断言 / stderr 告警可观测 / tombstone 记录保留在返回列表 / 排序有效;重放面:构造不抛 / 合法新记录生效);ctx 由 _case_shard 产出。
747
+ def g_c():
748
+ begin("C")
749
+ ctx = _case_shard()
750
+ recs = ctx["recs"]
751
+ stats = ctx["stats"] or (None, ())
752
+
753
+ check_fn("C1·read_all 不崩(非对象行不参与排序键)",
754
+ lambda c=ctx: c["error"] is None and c["error0"] is None,
755
+ "err=%r err0=%r" % (ctx["error"], ctx["error0"]))
756
+ check_fn("C2·只返回 dict 记录且条数正确(合法 1 条 + tombstone 1 条 = 2)",
757
+ lambda: isinstance(recs, list) and len(recs) == 2,
758
+ "recs=%r" % (recs,))
759
+ check_fn("C3·返回记录逐条是 dict(非对象行未混入)",
760
+ lambda: isinstance(recs, list) and all(isinstance(r, dict)
761
+ for r in recs),
762
+ "types=%s" % ([type(r).__name__ for r in recs]
763
+ if isinstance(recs, list) else None))
764
+ check_fn("C4·坏行计数可断言(本片 4 条非对象行全部记账)",
765
+ lambda: stats[0] == 4, "skips=%s" % (stats[0],))
766
+ check_fn("C5·stderr 告警可观测(不静默:含分片名与条数)",
767
+ lambda: "ShardedLog.read_all" in ctx["stderr"]
768
+ and "4 条非对象" in ctx["stderr"],
769
+ "stderr=%r" % (ctx["stderr"][:200],))
770
+ check_fn("C6·tombstone 记录仍保留在返回列表(e=None 不得当坏行丢掉)",
771
+ lambda: any(r.get("id") == "n2" and r.get("e") is None
772
+ for r in recs),
773
+ "recs=%r" % (recs,))
774
+ check_fn("C7·排序有效(按 (_t,_s) 单调不减,非对象行未污染次序)",
775
+ lambda: all((recs[i].get("_t", 0), recs[i].get("_s", 0))
776
+ <= (recs[i + 1].get("_t", 0), recs[i + 1].get("_s", 0))
777
+ for i in range(len(recs) - 1)),
778
+ "recs=%r" % (recs,))
779
+ check_fn("C8·重放面:合法快照 + 坏分片下 MdCG 构造不抛",
780
+ lambda c=ctx: c["error_replay"] is None,
781
+ "err=%r" % (ctx["error_replay"],))
782
+ check_fn("C9·重放面:坏分片里的合法新记录生效(c_new 可见)",
783
+ lambda c=ctx: c["nodes"] is not None and "c_new" in c["nodes"],
784
+ "nodes=%s" % (sorted(ctx["nodes"]) if ctx["nodes"] else None))
785
+
786
+
787
+ # ---------------- D stdio 端到端 ----------------
788
+
789
+ # 生效条件:跑 D 组 5 条断言(rc=0 / initialize 应答 / tools/list 应答 / 非对象行仍回 -32600 / 攻击行之后仍应答);ctx 由 _case_stdio 产出,spawn 失败或进程即死时五条各自判红。
790
+ def g_d():
791
+ begin("D")
792
+ ctx = _case_stdio()
793
+ by_id = ctx["by_id"]
794
+
795
+ def _init():
796
+ return ((by_id.get(1) or {}).get("result") or {}) \
797
+ .get("serverInfo", {}).get("name")
798
+
799
+ def _tools(rid):
800
+ return ((by_id.get(rid) or {}).get("result") or {}).get("tools")
801
+
802
+ check_fn("D1·非对象快照 + 坏分片下 stdio 主循环仍存活(rc=0)",
803
+ lambda c=ctx: c["rc"] == 0 and c["inject_ok"],
804
+ "rc=%r inject=%s stderr_tail=%r"
805
+ % (ctx["rc"], ctx["inject_ok"], ctx["err"][-300:]))
806
+ check_fn("D2·initialize 应答(serverInfo=mdcg-mcp)",
807
+ lambda: _init() == "mdcg-mcp" and ctx["inject_ok"],
808
+ "by_id[1]=%s" % (str(by_id.get(1))[:200],))
809
+ check_fn("D3·tools/list 应答且工具表非空",
810
+ lambda: isinstance(_tools(2), list) and len(_tools(2)) > 0
811
+ and ctx["inject_ok"],
812
+ "by_id[2]=%s" % (str(by_id.get(2))[:200],))
813
+ check_fn("D4·非对象行仍回 id=null 的 -32600(入口闸未因坏数据失能)",
814
+ lambda: ctx["inject_ok"] and any(
815
+ isinstance(o, dict) and o.get("id") is None
816
+ and _err_code(o) == -32600 for o in ctx["objs"]),
817
+ "objs=%s" % (json.dumps(ctx["objs"], ensure_ascii=False)[:300],))
818
+ check_fn("D5·攻击行之后的 tools/list 仍应答(不是半途死)",
819
+ lambda: isinstance(_tools(3), list) and len(_tools(3)) > 0
820
+ and ctx["inject_ok"],
821
+ "by_id[3]=%s" % (str(by_id.get(3))[:200],))
822
+
823
+
824
+ # ---------------- E 载荷坏型收口(契约②) ----------------
825
+
826
+ # 生效条件:跑 E 组 6 条断言(装载不抛 / 坏载荷不进 nodes / 合法 upsert 仍生效 / 记账可断言且不静默 / compact 面不抛且快照无坏载荷 id / 两面对同一分片的 nodes 逐键相等);ctx 由 _case_payload 产出,装载即失败时六条各自判红。
827
+ def g_e():
828
+ begin("E")
829
+ ctx = _case_payload()
830
+ bad_ids = {"p_arr", "p_int", "p_str"}
831
+ nodes = ctx["nodes"] or {}
832
+
833
+ check_fn("E1·装载面:坏载荷分片下 MdCG 构造不抛(e 非 dict 不进 _count_buckets)",
834
+ lambda c=ctx: c["err"] is None and c["err0"] is None and c["cg"] is not None,
835
+ "err=%r err0=%r" % (ctx["err"], ctx["err0"]))
836
+ check_fn("E2·装载面:三条坏载荷 id 一个都不进 nodes(跳过而非覆盖)",
837
+ lambda c=ctx: c["err"] is None and not (bad_ids & set(c["nodes"] or {})),
838
+ "nodes=%s" % (sorted(nodes) if nodes else None))
839
+ check_fn("E3·装载面:同批里的合法 upsert 仍生效(n1.bucket=n225pay)",
840
+ lambda: isinstance(nodes.get("n1"), dict)
841
+ and nodes["n1"].get("bucket") == "n225pay",
842
+ "n1=%r" % (nodes.get("n1"),))
843
+ check_fn("E4·装载面:坏载荷行计入记账面(子面 3 条)且不静默(stderr 含条数)",
844
+ lambda c=ctx: c["payload_before"] == 3
845
+ and "载荷坏型记录" in c["stderr"] and "3 条" in c["stderr"],
846
+ "payload=%r stderr=%r" % (ctx["payload_before"],
847
+ ctx["stderr"][:160]))
848
+ check_fn("E5·compact 面:不抛且落盘/返回索引里坏载荷 id 全不在(同一分片第二次重放)",
849
+ lambda c=ctx: c["err_compact"] is None
850
+ and isinstance(c["idx"], dict)
851
+ and not (bad_ids & set(c["idx"]["nodes"])),
852
+ "err=%r nodes=%s" % (ctx["err_compact"],
853
+ sorted(ctx["idx"]["nodes"])
854
+ if isinstance(ctx["idx"], dict) else None))
855
+ check_fn("E6·两面同口径:compact 后 nodes 与装载面逐键相等,且同批坏载荷同样记账(子面共 6 条)",
856
+ lambda c=ctx: isinstance(c["idx"], dict)
857
+ and c["idx"]["nodes"] == nodes
858
+ and c["payload_after"] == 6 and c["total_after"] == 6,
859
+ "equal=%s payload=%r total=%r" % (
860
+ (isinstance(ctx["idx"], dict) and ctx["idx"]["nodes"] == nodes),
861
+ ctx["payload_after"], ctx["total_after"]))
862
+
863
+
864
+ # ---------------- F 排序键健壮化(契约①) ----------------
865
+
866
+ # 生效条件:跑 F 组 5 条断言(read_all 不抛且旧键直排在同一批记录上必抛 / 合法记录与旧键 oracle 逐位一致 / 记录一个不丢且按键单调不减 / 坏型键计数可断言 / 不静默);ctx 由 _case_sortkey 产出,read_all 抛异常时五条各自判红。
867
+ def g_f():
868
+ begin("F")
869
+ ctx = _case_sortkey()
870
+ recs = ctx["recs"]
871
+ oracle = ctx["oracle"] or []
872
+ got = ctx["got_legal"]
873
+
874
+ check_fn("F1·read_all 不抛**且**旧键直排在同一批记录上必抛(fixture 有判别力)",
875
+ lambda c=ctx: c["err"] is None and c["err0"] is None
876
+ and c["old_raises"] is not False
877
+ and c["old_raises"] is not None,
878
+ "err=%r old=%r" % (ctx["err"], ctx["old_raises"]))
879
+ check_fn("F2·合法记录相对次序与改前逐位一致(与旧键 oracle 的序与内容全等)",
880
+ lambda: got is not None and oracle == got,
881
+ "oracle=%s got=%s" % ([r.get("id") for r in oracle],
882
+ [r.get("id") for r in got]
883
+ if got is not None else None))
884
+ check_fn("F3·记录一个不丢且返回列表按键单调不减(坏型槽记 0 参与排序)",
885
+ lambda c=ctx: isinstance(recs, list)
886
+ and len(recs) == len(ctx["rows"] or [])
887
+ and all(_norm_key(recs[i]) <= _norm_key(recs[i + 1])
888
+ for i in range(len(recs) - 1)),
889
+ "n=%s keys=%s" % (len(recs) if isinstance(recs, list) else None,
890
+ [_norm_key(r) for r in recs]
891
+ if isinstance(recs, list) else None))
892
+ check_fn("F4·坏型排序键计数可断言(str/list/None/NaN 四形态各 1 条 = 4)",
893
+ lambda c=ctx: c["sortkey_before"] == 4,
894
+ "n=%r samples=%s" % (ctx["sortkey_before"],
895
+ F.bad_sortkey_row_stats()[1][:2]))
896
+ check_fn("F5·不静默(stderr 含条数、分片名与两个槽类型)",
897
+ lambda c=ctx: "排序键坏型 4 条" in c["stderr"]
898
+ and "9999-badkey.log" in c["stderr"] and "_t=str" in c["stderr"],
899
+ "stderr=%r" % (ctx["stderr"][:200],))
900
+
901
+
902
+ # ---------------- G tombstone 语义(e is None ⇒ pop) ----------------
903
+
904
+ # 生效条件:跑 G 组 2 条断言(装载面 tombstone 重放生效、compact 面同款);ctx 由 _case_tombstone 产出,构造失败时两条各自判红。
905
+ def g_g():
906
+ begin("G")
907
+ ctx = _case_tombstone()
908
+ nodes = ctx["nodes"]
909
+
910
+ check_fn("G1·装载面:e is None ⇒ pop 该 nid(删除可重放,节点不复活)",
911
+ lambda c=ctx: c["err"] is None and c["nodes"] is not None
912
+ and "n2" not in c["nodes"] and "n1" in c["nodes"],
913
+ "err=%r nodes=%s" % (ctx["err"],
914
+ sorted(nodes) if nodes else None))
915
+ check_fn("G2·compact 面:快照与返回值同样 pop 该 nid(与装载面同口径)",
916
+ lambda c=ctx: c["err_compact"] is None
917
+ and isinstance(c["idx"], dict)
918
+ and "n2" not in c["idx"]["nodes"] and "n1" in c["idx"]["nodes"],
919
+ "err=%r nodes=%s" % (
920
+ ctx["err_compact"],
921
+ sorted(ctx["idx"]["nodes"])
922
+ if isinstance(ctx["idx"], dict) else None))
923
+
924
+
925
+ # ---------------- H read_jsonl 通用面(非对象行原样产出) ----------------
926
+
927
+ # 生效条件:跑 H 组 2 条断言(产出类型序列逐位一致、产出对象与 json.loads 同值——即只在 read_all 层过滤,read_jsonl 通用契约未改);ctx 由 _case_jsonl 产出。
928
+ def g_h():
929
+ begin("H")
930
+ ctx = _case_jsonl()
931
+ got = ctx["got"]
932
+
933
+ check_fn("H1·read_jsonl 原样产出非对象行(类型序列逐位一致)",
934
+ lambda c=ctx: c["error"] is None and isinstance(c["got"], list)
935
+ and [type(x).__name__ for x in c["got"]]
936
+ == ["dict", "NoneType", "list", "int", "str", "dict"],
937
+ "err=%r types=%s" % (ctx["error"],
938
+ [type(x).__name__ for x in got]
939
+ if isinstance(got, list) else None))
940
+ check_fn("H2·read_jsonl 不做类型改造(产出对象与 json.loads 结果同值)",
941
+ lambda c=ctx: isinstance(c["got"], list) and len(c["got"]) == 6
942
+ and c["got"][1] is None and c["got"][2] == [] and c["got"][3] == 123
943
+ and c["got"][4] == "abc",
944
+ "got=%r" % (got,))
945
+
946
+
947
+ _GROUPS = (("A", g_a), ("B", g_b), ("C", g_c), ("D", g_d), ("E", g_e),
948
+ ("F", g_f), ("G", g_g), ("H", g_h))
949
+
950
+
951
+ # 生效条件:重算计数器后依次跑 A–H 八组(静默与否由 print 决定),返回 (总失败数, {组名: 失败数} 的副本);供正常模式与 --branch-baseline 的变异核验共用。
952
+ def _run_groups():
953
+ _reset_counters()
954
+ for _name, fn in _GROUPS:
955
+ fn()
956
+ return FAIL, dict(_GROUP_FAILS)
957
+
958
+
959
+ # ---------------- 定点变异自证(--branch-baseline) ----------------
960
+
961
+ # 变异表:逐处关掉一个判据,守卫**必须转红**,且红项**恰好**落在该判据负责的
962
+ # 组、数量等于该组断言数(多一项少一项都报红)。基线源=**工作区源码**
963
+ # (inspect.getsource 取自当前已加载模块),绝不绑 git HEAD——本仓批次 80/81
964
+ # 两次教训:基线绑提交那一刻即失效。
965
+ #
966
+ # 组/判据一一对应(见模块头注「组与判据一一对应」):①装载面类型闸(A 20 +
967
+ # 端到端 D 5,D 的病理本就是非对象快照)②compact 面类型闸(B 16)③日志面非对象
968
+ # 行过滤(C 9 + D 5,D 的另一个病理是非对象行)④载荷坏型闸(E 6)⑤载荷闸口径
969
+ # 过宽(G 2——把 e is None 的 tombstone 也当坏载荷)⑥排序键归一(F 5)⑦tombstone
970
+ # 重放(G 2——与⑤从「过窄/过宽」两侧把同一口径钉死)⑧read_jsonl 原样产出(H 2)。
971
+ _MUTATIONS = (
972
+ ("装载面类型闸(_load_index 非对象视同损坏)",
973
+ "mdcg", "_load_index",
974
+ 'if isinstance(d, dict) and d.get("schema") == SCHEMA:',
975
+ 'if d.get("schema") == SCHEMA:',
976
+ {"A": 20, "D": 5}),
977
+ ("compact 面类型闸(非对象走 scan_fallback)",
978
+ "mdcg", "compact_index",
979
+ "if not isinstance(d, dict):", "if False:",
980
+ {"B": 16}),
981
+ ("分片日志非对象行过滤(read_all 跳过 + 记账)",
982
+ "fsutil", "read_all",
983
+ "if not isinstance(rec, dict):", "if False:",
984
+ {"C": 9, "D": 5}),
985
+ ("载荷坏型闸(_apply_log 跳过 e 非 dict 且非 None + 记账)",
986
+ "mdcg", "_apply_log",
987
+ "if e is not None and not isinstance(e, dict):", "if False:",
988
+ {"E": 6}),
989
+ ("载荷闸口径过宽(把 e is None 的 tombstone 也当坏载荷跳过)",
990
+ "mdcg", "_apply_log",
991
+ "if e is not None and not isinstance(e, dict):",
992
+ "if not isinstance(e, dict):",
993
+ {"G": 2}),
994
+ ("排序键归一(read_all:坏型槽记 0 而非直排)",
995
+ "fsutil", "read_all",
996
+ "isinstance(v, (int, float)) and v == v", "True",
997
+ {"F": 5}),
998
+ ("tombstone 重放(_apply_log:e is None ⇒ pop 该 nid)",
999
+ "mdcg", "_apply_log",
1000
+ "nodes.pop(nid, None)", "pass",
1001
+ {"G": 2}),
1002
+ ("read_jsonl 通用面(非对象行原样产出)",
1003
+ "fsutil_mod", "read_jsonl",
1004
+ "yield json.loads(line)",
1005
+ '_o = json.loads(line)\n yield _o if isinstance(_o, dict) else None',
1006
+ {"H": 2}),
1007
+ )
1008
+
1009
+
1010
+ # 生效条件:which 为 "mdcg" 时从 MdCG 类、为 "fsutil" 时从 ShardedLog 类、为 "fsutil_mod" 时从 fsutil 模块取 func_name 的源码文本;**从 `def <name>(` 行起截断再 dedent**——inspect.getsource 对本仓「顶格 `# 生效条件:` 注释 + 类内缩进 def」的排版会把注释/装饰器一并带来,直接 dedent 会因公共前缀为 0 而留下缩进(实测 IndentationError);函数缺失或无法取源时返回 None。
1011
+ def _func_src(which: str, func_name: str):
1012
+ try:
1013
+ if which == "mdcg":
1014
+ raw = MdCG.__dict__[func_name]
1015
+ elif which == "fsutil":
1016
+ raw = F.ShardedLog.__dict__[func_name]
1017
+ else: # fsutil_mod:模块级函数
1018
+ raw = F.__dict__[func_name]
1019
+ fn = getattr(raw, "__func__", raw)
1020
+ src = inspect.getsource(fn)
1021
+ return textwrap.dedent(src[src.index("def %s(" % func_name):])
1022
+ except Exception: # noqa: BLE001 —— 取源失败按锚点漂移处理
1023
+ return None
1024
+
1025
+
1026
+ # 生效条件:which 为 "mdcg"/"fsutil"/"fsutil_mod"、old 在 func_name 源码中时,编译替换后的源码 exec 进对应模块命名空间并挂回目标(类属性或模块级名字;原为 staticmethod 者仍包 staticmethod),返回还原回调;old 不在源码中返回 None(调用方按 ANCHOR-MISS 处置,退出码 2)。
1027
+ def _patch(which: str, func_name: str, old: str, new: str):
1028
+ src = _func_src(which, func_name)
1029
+ if src is None or old not in src:
1030
+ return None
1031
+ ns = dict(vars(M if which == "mdcg" else F))
1032
+ ns.setdefault("__name__", "n225_mut")
1033
+ exec(compile(src.replace(old, new), "n225_mut.py", "exec"), ns)
1034
+ holder = MdCG if which == "mdcg" else (F.ShardedLog if which == "fsutil"
1035
+ else F)
1036
+ orig = holder.__dict__[func_name]
1037
+ if isinstance(orig, staticmethod):
1038
+ setattr(holder, func_name, staticmethod(ns[func_name]))
1039
+ else:
1040
+ setattr(holder, func_name, ns[func_name])
1041
+
1042
+ def _restore():
1043
+ setattr(holder, func_name, orig)
1044
+
1045
+ return _restore
1046
+
1047
+
1048
+ # 生效条件:无入参,跑定点变异自证——先跑未变异基线(必须零失败),再逐个应用 _MUTATIONS:锚点缺失立即打印 ANCHOR-MISS 并返回 2(fail-closed,不再继续);变异后红项组与数量必须与预期完全一致,否则计入 bad;全部通过返回 0,存在不符返回 1。
1049
+ def _branch_baseline() -> int:
1050
+ print("!! 定点变异模式:逐处关掉判据,守卫应当转红且**恰好**命中预期组/项数\n")
1051
+ with contextlib.redirect_stdout(io.StringIO()):
1052
+ clean_fail, _clean_groups = _run_groups()
1053
+ print(" 未变异基线:失败=%d" % clean_fail)
1054
+ if clean_fail:
1055
+ print(" 基线即失败 → 定点变异自证无意义(先修基线)")
1056
+ return 1
1057
+ bad = []
1058
+ for label, which, fname, old, new, expect in _MUTATIONS:
1059
+ restore = _patch(which, fname, old, new)
1060
+ if restore is None:
1061
+ print(" ANCHOR-MISS %s —— 变异锚点漂移(实现改了却没同步本表;"
1062
+ "基线源=%s.%s)" % (label, which, fname))
1063
+ return 2 # fail-closed:锚点漂移不静默失效
1064
+ _MUTATION[0] = {"which": which, "func": fname, "old": old, "new": new}
1065
+ try:
1066
+ with contextlib.redirect_stdout(io.StringIO()):
1067
+ got_fail, got_groups = _run_groups()
1068
+ finally:
1069
+ _MUTATION[0] = None
1070
+ restore()
1071
+ _cleanup() # 每轮清干净:临时根与活实例不跨轮累积
1072
+ hit = {g: n for g, n in got_groups.items() if n}
1073
+ if hit == expect and got_fail == sum(expect.values()):
1074
+ print(" 关掉「%s」→ 红项=%d,命中组=%s %s"
1075
+ % (label, got_fail, dict(sorted(hit.items())),
1076
+ "(恰好命中预期)"))
1077
+ else:
1078
+ print(" 关掉「%s」→ 红项=%d,命中组=%s 期望=%s "
1079
+ "**红基线失效(判别力面不符)**"
1080
+ % (label, got_fail, dict(sorted(hit.items())),
1081
+ dict(sorted(expect.items()))))
1082
+ bad.append(label)
1083
+ print("\n定点变异自证:%s" % ("PASS(每处判据都有断言把它钉死)"
1084
+ if not bad else "FAIL —— " + "、".join(bad)))
1085
+ return 0 if not bad else 1
1086
+
1087
+
1088
+ # 生效条件:命令行含 --branch-baseline 时走定点变异自证(退出码 0/1/2);否则依次跑 A–H 八组打印逐条结果与汇总,全部通过返回 0、存在失败返回 1。
1089
+ def main() -> int:
1090
+ if "--branch-baseline" in sys.argv:
1091
+ try:
1092
+ return _branch_baseline()
1093
+ finally:
1094
+ _cleanup()
1095
+ try:
1096
+ _reset_counters()
1097
+ for _name, fn in _GROUPS:
1098
+ fn()
1099
+ finally:
1100
+ _cleanup()
1101
+ print("\n" + "=" * 64)
1102
+ print("非对象形态索引/日志守卫(N225):%d 通过,%d 失败" % (PASS, FAIL))
1103
+ if FAILS:
1104
+ for f in FAILS:
1105
+ print(" - %s" % f)
1106
+ return 0 if not FAILS else 1
1107
+
1108
+
1109
+ if __name__ == "__main__":
1110
+ sys.exit(main())