@hunter-harness/workflow-harness 0.2.58 → 0.2.60

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 (242) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/README.md +11 -13
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +13 -16
  4. package/harness/bundles/general/claude-code/harness-archive/checklist.md +5 -8
  5. package/harness/bundles/general/claude-code/harness-archive/reference.md +7 -16
  6. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +1 -7
  7. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -2
  13. package/harness/bundles/general/claude-code/harness-run/checklist.md +10 -9
  14. package/harness/bundles/general/claude-code/harness-run/protocols.md +2 -0
  15. package/harness/bundles/general/claude-code/harness-run/reference.md +19 -16
  16. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  18. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  19. package/harness/bundles/general/claude-code/harness-test/checklist.md +2 -2
  20. package/harness/bundles/general/claude-code/harness-test/pitfalls.md +3 -3
  21. package/harness/bundles/general/claude-code/harness-test/reference.md +2 -2
  22. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +16 -27
  23. package/harness/bundles/general/claude-code/protocols/evidence-based-reporting-protocol.md +5 -5
  24. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +6 -6
  25. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +5 -6
  26. package/harness/bundles/general/claude-code/protocols/sensitive-info-protocol.md +3 -3
  27. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +2 -4
  28. package/harness/bundles/general/claude-code/scripts/harness_archive.py +161 -987
  29. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  30. package/harness/bundles/general/codebuddy/README.md +11 -13
  31. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +13 -16
  32. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +5 -8
  33. package/harness/bundles/general/codebuddy/harness-archive/reference.md +7 -16
  34. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +1 -7
  35. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -2
  41. package/harness/bundles/general/codebuddy/harness-run/checklist.md +10 -9
  42. package/harness/bundles/general/codebuddy/harness-run/protocols.md +2 -0
  43. package/harness/bundles/general/codebuddy/harness-run/reference.md +19 -16
  44. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  45. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  46. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  47. package/harness/bundles/general/codebuddy/harness-test/checklist.md +2 -2
  48. package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +3 -3
  49. package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -2
  50. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +16 -27
  51. package/harness/bundles/general/codebuddy/protocols/evidence-based-reporting-protocol.md +5 -5
  52. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +6 -6
  53. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +5 -6
  54. package/harness/bundles/general/codebuddy/protocols/sensitive-info-protocol.md +3 -3
  55. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +2 -4
  56. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +161 -987
  57. package/harness/bundles/general/codex/.harness-build.json +1 -1
  58. package/harness/bundles/general/codex/README.md +11 -13
  59. package/harness/bundles/general/codex/harness-archive/SKILL.md +13 -16
  60. package/harness/bundles/general/codex/harness-archive/checklist.md +5 -8
  61. package/harness/bundles/general/codex/harness-archive/reference.md +7 -16
  62. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +1 -7
  63. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -2
  69. package/harness/bundles/general/codex/harness-run/checklist.md +10 -9
  70. package/harness/bundles/general/codex/harness-run/protocols.md +2 -0
  71. package/harness/bundles/general/codex/harness-run/reference.md +19 -16
  72. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  73. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  74. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  75. package/harness/bundles/general/codex/harness-test/checklist.md +2 -2
  76. package/harness/bundles/general/codex/harness-test/pitfalls.md +3 -3
  77. package/harness/bundles/general/codex/harness-test/reference.md +2 -2
  78. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +16 -27
  79. package/harness/bundles/general/codex/protocols/evidence-based-reporting-protocol.md +5 -5
  80. package/harness/bundles/general/codex/protocols/ledger-protocol.md +6 -6
  81. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +5 -6
  82. package/harness/bundles/general/codex/protocols/sensitive-info-protocol.md +3 -3
  83. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +2 -4
  84. package/harness/bundles/general/codex/scripts/harness_archive.py +161 -987
  85. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  86. package/harness/bundles/general/cursor/README.md +11 -13
  87. package/harness/bundles/general/cursor/harness-archive/SKILL.md +13 -16
  88. package/harness/bundles/general/cursor/harness-archive/checklist.md +5 -8
  89. package/harness/bundles/general/cursor/harness-archive/reference.md +7 -16
  90. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +1 -7
  91. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  94. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -2
  97. package/harness/bundles/general/cursor/harness-run/checklist.md +10 -9
  98. package/harness/bundles/general/cursor/harness-run/protocols.md +2 -0
  99. package/harness/bundles/general/cursor/harness-run/reference.md +19 -16
  100. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  101. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  102. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  103. package/harness/bundles/general/cursor/harness-test/checklist.md +2 -2
  104. package/harness/bundles/general/cursor/harness-test/pitfalls.md +3 -3
  105. package/harness/bundles/general/cursor/harness-test/reference.md +2 -2
  106. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +16 -27
  107. package/harness/bundles/general/cursor/protocols/evidence-based-reporting-protocol.md +5 -5
  108. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +6 -6
  109. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +5 -6
  110. package/harness/bundles/general/cursor/protocols/sensitive-info-protocol.md +3 -3
  111. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +2 -4
  112. package/harness/bundles/general/cursor/scripts/harness_archive.py +161 -987
  113. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  114. package/harness/bundles/java/claude-code/README.md +11 -13
  115. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +13 -16
  117. package/harness/bundles/java/claude-code/harness-archive/checklist.md +5 -8
  118. package/harness/bundles/java/claude-code/harness-archive/reference.md +7 -16
  119. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +1 -7
  120. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  123. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  126. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -2
  127. package/harness/bundles/java/claude-code/harness-run/protocols.md +2 -0
  128. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  129. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  130. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  131. package/harness/bundles/java/claude-code/harness-test/checklist.md +2 -2
  132. package/harness/bundles/java/claude-code/harness-test/pitfalls.md +3 -3
  133. package/harness/bundles/java/claude-code/harness-test/reference.md +2 -2
  134. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +16 -27
  135. package/harness/bundles/java/claude-code/protocols/evidence-based-reporting-protocol.md +5 -5
  136. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +6 -6
  137. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +5 -6
  138. package/harness/bundles/java/claude-code/protocols/sensitive-info-protocol.md +3 -3
  139. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +2 -4
  140. package/harness/bundles/java/claude-code/scripts/harness_archive.py +161 -987
  141. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  142. package/harness/bundles/java/codebuddy/README.md +11 -13
  143. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  144. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +13 -16
  145. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +5 -8
  146. package/harness/bundles/java/codebuddy/harness-archive/reference.md +7 -16
  147. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +1 -7
  148. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  149. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  150. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  153. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  154. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -2
  155. package/harness/bundles/java/codebuddy/harness-run/protocols.md +2 -0
  156. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  157. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  158. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  159. package/harness/bundles/java/codebuddy/harness-test/checklist.md +2 -2
  160. package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +3 -3
  161. package/harness/bundles/java/codebuddy/harness-test/reference.md +2 -2
  162. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +16 -27
  163. package/harness/bundles/java/codebuddy/protocols/evidence-based-reporting-protocol.md +5 -5
  164. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +6 -6
  165. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +5 -6
  166. package/harness/bundles/java/codebuddy/protocols/sensitive-info-protocol.md +3 -3
  167. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +2 -4
  168. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +161 -987
  169. package/harness/bundles/java/codex/.harness-build.json +1 -1
  170. package/harness/bundles/java/codex/README.md +11 -13
  171. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  172. package/harness/bundles/java/codex/harness-archive/SKILL.md +13 -16
  173. package/harness/bundles/java/codex/harness-archive/checklist.md +5 -8
  174. package/harness/bundles/java/codex/harness-archive/reference.md +7 -16
  175. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +1 -7
  176. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  177. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  178. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  179. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  180. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  181. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  182. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -2
  183. package/harness/bundles/java/codex/harness-run/protocols.md +2 -0
  184. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  185. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  186. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  187. package/harness/bundles/java/codex/harness-test/checklist.md +2 -2
  188. package/harness/bundles/java/codex/harness-test/pitfalls.md +3 -3
  189. package/harness/bundles/java/codex/harness-test/reference.md +2 -2
  190. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +16 -27
  191. package/harness/bundles/java/codex/protocols/evidence-based-reporting-protocol.md +5 -5
  192. package/harness/bundles/java/codex/protocols/ledger-protocol.md +6 -6
  193. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +5 -6
  194. package/harness/bundles/java/codex/protocols/sensitive-info-protocol.md +3 -3
  195. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +2 -4
  196. package/harness/bundles/java/codex/scripts/harness_archive.py +161 -987
  197. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  198. package/harness/bundles/java/cursor/README.md +11 -13
  199. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  200. package/harness/bundles/java/cursor/harness-archive/SKILL.md +13 -16
  201. package/harness/bundles/java/cursor/harness-archive/checklist.md +5 -8
  202. package/harness/bundles/java/cursor/harness-archive/reference.md +7 -16
  203. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +1 -7
  204. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  205. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  206. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  207. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  208. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  209. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  210. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -2
  211. package/harness/bundles/java/cursor/harness-run/protocols.md +2 -0
  212. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  213. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  214. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  215. package/harness/bundles/java/cursor/harness-test/checklist.md +2 -2
  216. package/harness/bundles/java/cursor/harness-test/pitfalls.md +3 -3
  217. package/harness/bundles/java/cursor/harness-test/reference.md +2 -2
  218. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +16 -27
  219. package/harness/bundles/java/cursor/protocols/evidence-based-reporting-protocol.md +5 -5
  220. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +6 -6
  221. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +5 -6
  222. package/harness/bundles/java/cursor/protocols/sensitive-info-protocol.md +3 -3
  223. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +2 -4
  224. package/harness/bundles/java/cursor/scripts/harness_archive.py +161 -987
  225. package/harness/manifests/general/claude-code.json +30 -34
  226. package/harness/manifests/general/codebuddy.json +30 -34
  227. package/harness/manifests/general/codex.json +30 -34
  228. package/harness/manifests/general/cursor.json +30 -34
  229. package/harness/manifests/java/claude-code.json +30 -34
  230. package/harness/manifests/java/codebuddy.json +30 -34
  231. package/harness/manifests/java/codex.json +30 -34
  232. package/harness/manifests/java/cursor.json +30 -34
  233. package/hunter-workflow-family.json +5 -5
  234. package/package.json +1 -1
  235. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +0 -299
  236. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +0 -299
  237. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +0 -299
  238. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +0 -299
  239. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +0 -299
  240. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +0 -299
  241. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +0 -299
  242. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +0 -299
@@ -3,8 +3,8 @@
3
3
 
4
4
  Subcommands:
5
5
  status — pre-archive gate checks (read-only)
6
- finalize — single-process archive: manifest → move → collect → render
7
- validate → after-manifest → publish → ZIP upload/service
6
+ finalize — single-process archive: manifest → move → collect → validate
7
+ after-manifest → publish → ZIP upload/service
8
8
  replay — read-only re-collect + validate for historical archives
9
9
 
10
10
  Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
@@ -43,7 +43,6 @@ if hasattr(sys.stderr, "reconfigure"):
43
43
 
44
44
  SCRIPTS_DIR = Path(__file__).resolve().parent
45
45
  SKILLS_ROOT = SCRIPTS_DIR.parent
46
- RENDER_SCRIPT = SKILLS_ROOT / "harness-archive" / "templates" / "render-summary.mjs"
47
46
  SUMMARY_TEMPLATE = (
48
47
  SKILLS_ROOT / "harness-archive" / "templates" / "summary-data-template.json"
49
48
  )
@@ -3198,7 +3197,11 @@ def build_verification_projection(
3198
3197
  "apiTests": _ledger_api_tests(effective_ledger, change_dir=change_dir),
3199
3198
  }
3200
3199
  db_compatibility = _ledger_db_compat(effective_ledger)
3201
- if db_compatibility["status"] == "NOT_RUN" and change_dir is not None:
3200
+ if db_compatibility["status"] in {
3201
+ "NOT_RUN",
3202
+ "UNKNOWN",
3203
+ "EVIDENCE_MISSING",
3204
+ } and change_dir is not None:
3202
3205
  gate_policy_path = change_dir / "meta" / "gate-policy.json"
3203
3206
  try:
3204
3207
  gate_policy = read_json(gate_policy_path) if gate_policy_path.is_file() else None
@@ -3936,8 +3939,16 @@ def _resolve_base_commit(
3936
3939
 
3937
3940
  Latest merge phase context must not override the full change boundary.
3938
3941
  """
3942
+ def stable_base(value: Any) -> str:
3943
+ candidate = str(value or "").strip()
3944
+ if candidate.upper() in {"HEAD", "FETCH_HEAD", "ORIG_HEAD"}:
3945
+ return ""
3946
+ if candidate.startswith(("refs/", "@{")):
3947
+ return ""
3948
+ return candidate
3949
+
3939
3950
  if ledger:
3940
- base = str(ledger.get("baseCommit") or "").strip()
3951
+ base = stable_base(ledger.get("baseCommit"))
3941
3952
  if base and base != NOT_AVAILABLE:
3942
3953
  return base
3943
3954
 
@@ -3959,7 +3970,7 @@ def _resolve_base_commit(
3959
3970
  continue
3960
3971
  if not isinstance(payload, dict):
3961
3972
  continue
3962
- base = str(payload.get("baseCommit") or "").strip()
3973
+ base = stable_base(payload.get("baseCommit"))
3963
3974
  if base and base != NOT_AVAILABLE:
3964
3975
  return base
3965
3976
 
@@ -3996,9 +4007,18 @@ def _final_commit_from_sources(
3996
4007
  if str(event.get("phase") or "").lower() not in {"submit", "merge", "archive"}:
3997
4008
  continue
3998
4009
  text = " ".join(str(event.get(key) or "") for key in ("note", "message", "command"))
3999
- match = commit_pattern.search(text)
4000
- if match:
4001
- return match.group(0)
4010
+ for match in commit_pattern.finditer(text):
4011
+ candidate = match.group(0)
4012
+ code, resolved, _ = git_run(
4013
+ project,
4014
+ "rev-parse",
4015
+ "--verify",
4016
+ f"{candidate}^{{commit}}",
4017
+ )
4018
+ resolved_lines = str(resolved or "").splitlines()
4019
+ resolved_hash = resolved_lines[0].strip() if resolved_lines else ""
4020
+ if code == 0 and re.fullmatch(r"[0-9a-f]{40}", resolved_hash, re.IGNORECASE):
4021
+ return resolved_hash
4002
4022
  if existing and existing.get("finalCommit"):
4003
4023
  return str(existing["finalCommit"])
4004
4024
  code, head, _ = git_run(project, "rev-parse", "HEAD")
@@ -4669,618 +4689,6 @@ def collect_summary_data(
4669
4689
 
4670
4690
 
4671
4691
  # ---------------------------------------------------------------------------
4672
- # render
4673
- # ---------------------------------------------------------------------------
4674
-
4675
-
4676
- def resolve_node_path(project_root: Path) -> str | None:
4677
- profile = project_root / ".harness" / "config" / "build-profile.json"
4678
- if profile.is_file():
4679
- try:
4680
- data = read_json(profile)
4681
- node = (data.get("toolPaths") or {}).get("node")
4682
- if node and Path(str(node)).exists():
4683
- return str(node)
4684
- except (OSError, json.JSONDecodeError, TypeError):
4685
- pass
4686
- return shutil.which("node")
4687
-
4688
-
4689
- def _render_legacy_fallback_html(summary: dict[str, Any]) -> str:
4690
- """Render escaped, deterministic HTML with every validate-required fact.
4691
-
4692
- Used when the Node renderer is unavailable or fails. No timestamps / random
4693
- data; all dynamic values are HTML-escaped via _html_escape.
4694
- """
4695
- def esc(v: Any) -> str:
4696
- return _html_escape("" if v is None else str(v))
4697
-
4698
- parts: list[str] = [
4699
- "<!DOCTYPE html>",
4700
- '<html lang="zh-CN"><head><meta charset="utf-8">',
4701
- "<title>harness final-summary (python fallback)</title>",
4702
- "</head><body>",
4703
- "<h1>变更最终报告(Python fallback 渲染)</h1>",
4704
- f'<h2 id="changeName">{esc(summary.get("changeName"))}</h2>',
4705
- f'<p><strong>finalStatus</strong>: '
4706
- f'<span id="finalStatus">{esc(summary.get("finalStatus"))}</span></p>',
4707
- f'<div id="finalStatusReasons"><strong>finalStatusReasons</strong><ul>',
4708
- ]
4709
- for reason in summary.get("finalStatusReasons") or []:
4710
- parts.append(f"<li>{esc(reason)}</li>")
4711
- parts.append("</ul></div>")
4712
- if summary.get("riskTier"):
4713
- parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
4714
- parts.append("<h3>Current Outcome</h3>")
4715
- parts.append(
4716
- f"<p>finalStatus={esc(summary.get('finalStatus'))}</p>"
4717
- )
4718
-
4719
- candidate = (
4720
- summary.get("candidateVerification")
4721
- if isinstance(summary.get("candidateVerification"), dict)
4722
- else {}
4723
- )
4724
- parts.append("<h3>Candidate Claim / Attestation</h3>")
4725
- parts.append(
4726
- f"<p>assurance={esc(candidate.get('assurance'))} "
4727
- f"code={esc(candidate.get('code'))} "
4728
- f"provider={esc(candidate.get('provider'))}</p>"
4729
- )
4730
-
4731
- integrity = (
4732
- summary.get("archiveIntegrity")
4733
- if isinstance(summary.get("archiveIntegrity"), dict)
4734
- else {}
4735
- )
4736
- parts.append("<h3>Archive Integrity</h3>")
4737
- parts.append(
4738
- f"<p>ok={esc(integrity.get('ok'))} "
4739
- f"code={esc(integrity.get('code'))} "
4740
- f"checksumStatus={esc(integrity.get('checksumStatus'))}</p>"
4741
- )
4742
-
4743
- decision = (
4744
- summary.get("releaseDecision")
4745
- if isinstance(summary.get("releaseDecision"), dict)
4746
- else {}
4747
- )
4748
- parts.append("<h3>Release Eligibility</h3>")
4749
- parts.append(
4750
- f"<p>releaseEligible={esc(decision.get('releaseEligible'))} "
4751
- f"code={esc(decision.get('code'))}</p><ul>"
4752
- )
4753
- checks = decision.get("checks")
4754
- checks = checks if isinstance(checks, dict) else {}
4755
- for name, check in checks.items():
4756
- check = check if isinstance(check, dict) else {}
4757
- parts.append(
4758
- f"<li>{esc(name)}: ok={esc(check.get('ok'))} "
4759
- f"code={esc(check.get('code'))}</li>"
4760
- )
4761
- parts.append("</ul>")
4762
-
4763
- timing = summary.get("timing") if isinstance(summary.get("timing"), dict) else {}
4764
- if timing:
4765
- parts.append("<h3>Timing</h3>")
4766
- parts.append(
4767
- "<p id=\"timingColumns\">"
4768
- f"stageActiveExecution={esc(timing.get('stageActiveExecutionMs'))} · "
4769
- f"stageWallClockSpan={esc(timing.get('stageWallClockSpanMs'))} · "
4770
- f"workflowWallClock={esc(timing.get('workflowWallClockMs'))}"
4771
- "</p>"
4772
- )
4773
- parts.append(
4774
- "<p id=\"timingConservation\">"
4775
- f"attributedStageUnionMs={esc(timing.get('attributedStageUnionMs'))} · "
4776
- f"externalWaitMs={esc(timing.get('externalWaitMs'))} · "
4777
- f"pausedMs={esc(timing.get('pausedMs'))} · "
4778
- "agentOrToolUnattributedMs="
4779
- f"{esc(timing.get('agentOrToolUnattributedMs'))} · "
4780
- f"conservationDeltaMs={esc(timing.get('conservationDeltaMs'))}"
4781
- "</p>"
4782
- )
4783
- parts.append(
4784
- f"<p><strong>reportCutoffAt</strong>: "
4785
- f"<span id=\"reportCutoffAt\">{esc(timing.get('reportCutoffAt'))}</span></p>"
4786
- )
4787
- parts.append(
4788
- "<p><small>durations.totalMinutes is active-only; "
4789
- "do not treat it as workflow wall clock.</small></p>"
4790
- )
4791
- parts.append("<h3>History Quality</h3><ul>")
4792
- attempts = timing.get("attempts")
4793
- attempts = attempts if isinstance(attempts, list) else []
4794
- for attempt in attempts:
4795
- attempt = attempt if isinstance(attempt, dict) else {}
4796
- parts.append(
4797
- f"<li>{esc(attempt.get('phase'))}: "
4798
- f"{esc(attempt.get('terminalStatus') or attempt.get('status'))} "
4799
- f"durationMs={esc(attempt.get('durationMs'))}</li>"
4800
- )
4801
- if not attempts:
4802
- parts.append("<li>no typed attempts recorded</li>")
4803
- parts.append("</ul>")
4804
-
4805
- remote_cost = (
4806
- summary.get("remoteCost")
4807
- if isinstance(summary.get("remoteCost"), dict)
4808
- else {}
4809
- )
4810
- remote_totals = remote_cost.get("totals")
4811
- remote_totals = remote_totals if isinstance(remote_totals, dict) else {}
4812
- parts.append("<h3>Remote Cost</h3>")
4813
- parts.append(
4814
- f"<p>runnerMinutes={esc(remote_totals.get('runnerMinutes'))} "
4815
- f"queueWaitMs={esc(remote_totals.get('queueWaitMs'))} "
4816
- f"artifactBytes={esc(remote_totals.get('artifactBytes'))} "
4817
- f"duplicateRunCount={esc(remote_totals.get('duplicateRunCount'))}</p>"
4818
- )
4819
-
4820
- storage = (
4821
- summary.get("artifactStorage")
4822
- if isinstance(summary.get("artifactStorage"), dict)
4823
- else {}
4824
- )
4825
- parts.append("<h3>Artifact Storage</h3>")
4826
- parts.append(
4827
- f"<p>artifactCount={esc(storage.get('artifactCount'))} "
4828
- f"bytesAdded={esc(storage.get('bytesAdded'))} "
4829
- f"bytesReused={esc(storage.get('bytesReused'))} "
4830
- f"bytesPruned={esc(storage.get('bytesPruned'))}</p>"
4831
- )
4832
-
4833
- projection = (
4834
- summary.get("projection")
4835
- if isinstance(summary.get("projection"), dict)
4836
- else {}
4837
- )
4838
- parts.append("<h3>Projection / Fallback</h3>")
4839
- parts.append(
4840
- f"<p>mode={esc(projection.get('mode'))} "
4841
- f"code={esc(projection.get('code'))} "
4842
- f"remediation={esc(projection.get('remediation'))}</p>"
4843
- )
4844
-
4845
- if summary.get("productCommit") or summary.get("productTreeHash"):
4846
- parts.append("<h3>Identity</h3>")
4847
- parts.append(
4848
- f"<p>productCommit={esc(summary.get('productCommit'))} "
4849
- f"productTreeHash={esc(summary.get('productTreeHash'))} "
4850
- f"archiveCommit={esc(summary.get('archiveCommit'))}</p>"
4851
- )
4852
-
4853
- pipeline = summary.get("reportPipeline") or {}
4854
- cmds = pipeline.get("commands") or []
4855
- parts.append("<h3>Commands</h3><ul>")
4856
- for c in cmds:
4857
- parts.append(
4858
- f"<li><code>{esc(c.get('command'))}</code> "
4859
- f"exitCode={esc(c.get('exit_code'))}</li>"
4860
- )
4861
- parts.append("</ul>")
4862
-
4863
- ver = summary.get("verification") or {}
4864
- unit = ver.get("unitTests") or {}
4865
- api = ver.get("apiTests") or {}
4866
- browser = ver.get("browserE2E") or {}
4867
- parts.append("<h3>Verification</h3>")
4868
- parts.append(
4869
- "<p>unitTests: run={run} failures={failures} errors={errors} "
4870
- "skipped={skipped} passRate={passRate} status={status}</p>".format(
4871
- run=esc(unit.get("run")),
4872
- failures=esc(unit.get("failures")),
4873
- errors=esc(unit.get("errors")),
4874
- skipped=esc(unit.get("skipped")),
4875
- passRate=esc(unit.get("passRate")),
4876
- status=esc(unit.get("status")),
4877
- )
4878
- )
4879
- parts.append(
4880
- "<p>apiTests: status={status} total={total} passed={passed} "
4881
- "failed={failed} blocked={blocked}</p>".format(
4882
- status=esc(api.get("status")),
4883
- total=esc(api.get("total")),
4884
- passed=esc(api.get("passed")),
4885
- failed=esc(api.get("failed")),
4886
- blocked=esc(api.get("blocked")),
4887
- )
4888
- )
4889
- parts.append(
4890
- "<p>browserE2E: status={status} total={total} passed={passed} "
4891
- "failed={failed} skipped={skipped} retries={retries}</p>".format(
4892
- status=esc(browser.get("status")),
4893
- total=esc(browser.get("total")),
4894
- passed=esc(browser.get("passed")),
4895
- failed=esc(browser.get("failed")),
4896
- skipped=esc(browser.get("skipped")),
4897
- retries=esc(browser.get("retries")),
4898
- )
4899
- )
4900
- parts.append(f"<p>dbCompatibility: {esc(ver.get('dbCompatibility'))}</p>")
4901
-
4902
- parts.append("<h3>Changed Files</h3><ul>")
4903
- for f in summary.get("changedFiles") or []:
4904
- parts.append(
4905
- f"<li>{esc(f.get('path'))} +{esc(f.get('insertions'))} "
4906
- f"-{esc(f.get('deletions'))}</li>"
4907
- )
4908
- parts.append("</ul>")
4909
-
4910
- am = summary.get("archiveManifest") or {}
4911
- parts.append("<h3>Archive Manifest</h3>")
4912
- parts.append(
4913
- "<p>movedFiles={moved} generatedFiles={gen} totalArchiveFiles={total} "
4914
- "checksumStatus={cs}</p>".format(
4915
- moved=esc(am.get("movedFiles")),
4916
- gen=esc(am.get("generatedFiles")),
4917
- total=esc(am.get("totalArchiveFiles")),
4918
- cs=esc(am.get("checksumStatus")),
4919
- )
4920
- )
4921
-
4922
- def _list_section(title: str, items: Any) -> None:
4923
- parts.append(f"<h3>{esc(title)}</h3><ul>")
4924
- for it in items or []:
4925
- parts.append(f"<li>{esc(it)}</li>")
4926
- parts.append("</ul>")
4927
-
4928
- _list_section("Known Risks", summary.get("knownRisks"))
4929
- _list_section("Manual Actions", summary.get("manualActions"))
4930
- _list_section("Maintenance Notes", summary.get("maintenanceNotes"))
4931
-
4932
- parts.append("</body></html>")
4933
- return "\n".join(parts) + "\n"
4934
-
4935
-
4936
- def render_fallback_html(summary: dict[str, Any]) -> str:
4937
- """Render the same executive information architecture without Node."""
4938
-
4939
- def esc(value: Any) -> str:
4940
- return _html_escape("" if value is None else str(value))
4941
-
4942
- def rec(value: Any) -> dict[str, Any]:
4943
- return value if isinstance(value, dict) else {}
4944
-
4945
- def seq(value: Any) -> list[Any]:
4946
- return value if isinstance(value, list) else []
4947
-
4948
- normalized = rec(summary.get("normalizedReport"))
4949
- outcomes = rec(normalized.get("outcomes"))
4950
- current = rec(outcomes.get("current")) or {
4951
- "status": summary.get("finalStatus"),
4952
- "reasons": seq(summary.get("finalStatusReasons")),
4953
- "stages": rec(summary.get("stageStatus")),
4954
- "knownRisks": seq(summary.get("knownRisks")),
4955
- }
4956
- release = rec(outcomes.get("release")) or {
4957
- "intent": summary.get("archiveIntent"),
4958
- "decision": "NOT_REQUESTED"
4959
- if summary.get("archiveIntent") == "record-only"
4960
- else rec(summary.get("releaseDecision")).get("code"),
4961
- "eligible": rec(summary.get("releaseDecision")).get("releaseEligible"),
4962
- }
4963
- identity = rec(normalized.get("identity")) or rec(summary.get("changeIdentity"))
4964
- verification = rec(normalized.get("verification")) or rec(
4965
- summary.get("verification")
4966
- )
4967
- timing = rec(normalized.get("timing")) or rec(summary.get("timing"))
4968
- measurements = rec(normalized.get("measurements"))
4969
- record_only = (
4970
- release.get("intent") == "record-only"
4971
- or summary.get("archiveIntent") == "record-only"
4972
- )
4973
- product_commit = (
4974
- identity.get("productCommit")
4975
- or summary.get("productCommit")
4976
- or summary.get("finalCommit")
4977
- or "N/A"
4978
- )
4979
- labels = [
4980
- ("后端", ("unitTests", "dbCompatibility")),
4981
- ("Geo", ("geo",)),
4982
- ("前端", ("frontend",)),
4983
- ("浏览器", ("browserE2E",)),
4984
- ("API", ("apiTests",)),
4985
- ]
4986
- status_labels = {
4987
- "OK": "通过",
4988
- "PASS": "通过",
4989
- "PASSED": "通过",
4990
- "CONDITIONAL_OK": "有条件通过",
4991
- "WARN": "警告",
4992
- "ADVISORY": "建议",
4993
- "FAIL": "失败",
4994
- "FAILED": "失败",
4995
- "ERROR": "错误",
4996
- "BLOCKED": "阻塞",
4997
- "NOT_RUN": "未运行",
4998
- "SKIPPED": "已跳过",
4999
- "NOT_APPLICABLE": "不适用",
5000
- "UNKNOWN": "未知",
5001
- }
5002
-
5003
- def status_label(value: Any) -> str:
5004
- status = str(value or "UNKNOWN").upper()
5005
- return status_labels.get(status, status)
5006
-
5007
- def verification_status(value: Any) -> str:
5008
- if isinstance(value, str):
5009
- return value
5010
- item = rec(value)
5011
- if item.get("status"):
5012
- return str(item["status"])
5013
- if int(item.get("failures") or 0) + int(item.get("errors") or 0):
5014
- return "FAIL"
5015
- if int(item.get("run") or item.get("total") or 0):
5016
- return "OK"
5017
- return "NOT_RUN"
5018
-
5019
- def describe(value: Any) -> str:
5020
- if isinstance(value, str):
5021
- return value
5022
- item = rec(value)
5023
- return str(
5024
- item.get("title")
5025
- or item.get("message")
5026
- or item.get("summary")
5027
- or item.get("action")
5028
- or item.get("remediation")
5029
- or item
5030
- )
5031
-
5032
- def measurement_label(value: Any) -> str:
5033
- item = rec(value)
5034
- if item.get("state") in {"unknown", "not_applicable"}:
5035
- return "N/A"
5036
- if item.get("state") == "zero":
5037
- return "0"
5038
- if item.get("state") == "known":
5039
- return str(item.get("value"))
5040
- return "N/A" if value is None or value == "" else str(value)
5041
-
5042
- risks = (
5043
- seq(current.get("findings"))
5044
- or seq(current.get("knownRisks"))
5045
- or seq(summary.get("knownRisks"))
5046
- )
5047
- actions = seq(summary.get("manualActions"))
5048
- groups: list[str] = []
5049
- passed = 0
5050
- for label, keys in labels:
5051
- statuses = [
5052
- verification_status(verification[key])
5053
- for key in keys
5054
- if key in verification
5055
- ]
5056
- status = (
5057
- "FAIL"
5058
- if any(value in {"FAIL", "ERROR", "BLOCKED"} for value in statuses)
5059
- else "WARN"
5060
- if any("WARN" in value or "CONDITIONAL" in value for value in statuses)
5061
- else "NOT_APPLICABLE"
5062
- if statuses and all(value == "NOT_APPLICABLE" for value in statuses)
5063
- else "OK"
5064
- if statuses
5065
- and all(
5066
- value in {"OK", "PASS", "PASSED", "NOT_APPLICABLE"}
5067
- for value in statuses
5068
- )
5069
- else "NOT_RUN"
5070
- )
5071
- if status == "OK":
5072
- passed += 1
5073
- details = " · ".join(
5074
- str(rec(verification.get(key)).get("passRate") or status)
5075
- for key in keys
5076
- if key in verification
5077
- ) or "未配置该组验证"
5078
- raw_details = " · ".join(
5079
- f"{key} · status={verification_status(verification[key])}"
5080
- + (
5081
- f" · failed={rec(verification[key]).get('failed')}"
5082
- if rec(verification[key]).get("failed") is not None
5083
- else ""
5084
- )
5085
- for key in keys
5086
- if key in verification
5087
- )
5088
- groups.append(
5089
- f'<article class="verify" title="{esc(raw_details)}"><strong>{esc(label)}</strong>'
5090
- f'<span title="{esc(status)}">{esc(status_label(status))}</span><small>{esc(details)}</small></article>'
5091
- )
5092
-
5093
- commands = seq(rec(summary.get("reportPipeline")).get("commands"))
5094
- command_rows = "".join(
5095
- f"<tr><td>{esc(item.get('phase') or '-')}</td>"
5096
- f"<td><code>{esc(item.get('command'))}</code></td>"
5097
- f"<td>{esc('OK' if int(item.get('exit_code', item.get('exitCode', 1))) == 0 else 'FAIL')}</td></tr>"
5098
- for item in commands
5099
- if isinstance(item, dict)
5100
- ) or '<tr><td colspan="3">没有命令证据</td></tr>'
5101
- files = seq(summary.get("changedFiles"))
5102
- file_rows = "".join(
5103
- f"<tr><td><code>{esc(item.get('path') or item.get('file'))}</code></td>"
5104
- f"<td>+{esc(item.get('insertions') or 0)}</td>"
5105
- f"<td>-{esc(item.get('deletions') or 0)}</td></tr>"
5106
- for item in files
5107
- if isinstance(item, dict)
5108
- ) or '<tr><td colspan="3">没有变更文件证据</td></tr>'
5109
- risk_rows = "".join(f"<li>{esc(describe(item))}</li>" for item in risks) or "<li>当前没有未处置风险</li>"
5110
- action_rows = "".join(f"<li>{esc(describe(item))}</li>" for item in actions) or "<li>无需人工后续动作</li>"
5111
- release_card = (
5112
- ""
5113
- if record_only
5114
- else "<article class=\"card\"><h2>发布与候选</h2>"
5115
- f"<p>候选证明 / 发布资格:{esc(release.get('decision') or 'NOT_EVALUATED')}</p></article>"
5116
- )
5117
- remote = rec(measurements.get("remoteCost"))
5118
- storage = rec(measurements.get("artifactStorage"))
5119
- remote_totals = rec(remote.get("totals"))
5120
- projection = rec(summary.get("projection"))
5121
- archive_integrity = rec(summary.get("archiveIntegrity"))
5122
- durability = rec(summary.get("archiveDurability"))
5123
- durability_status = str(
5124
- durability.get("status") or "ARCHIVED_LOCAL_ONLY"
5125
- )
5126
- scenario_coverage = rec(summary.get("scenarioCoverage"))
5127
- unexecuted_scenarios = [
5128
- str(item) for item in seq(scenario_coverage.get("unexecuted"))
5129
- ]
5130
- scenario_status = str(
5131
- scenario_coverage.get("code")
5132
- or ("SCENARIO_COVERAGE_OK" if scenario_coverage.get("ok") else "NOT_RUN")
5133
- )
5134
- total_files = rec(summary.get("archiveManifest")).get("totalArchiveFiles")
5135
- total_files_text = "" if total_files is None else str(total_files)
5136
-
5137
- return f"""<!doctype html>
5138
- <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5139
- <title>Harness 最终报告 · {esc(summary.get("changeName"))}</title>
5140
- <style>
5141
- :root{{--bg:#f4f6f8;--card:#fff;--text:#172033;--muted:#667085;--line:#dfe5ec}}
5142
- @media(prefers-color-scheme:dark){{:root{{--bg:#0b1119;--card:#121a26;--text:#eef3f9;--muted:#9aa8ba;--line:#2a384a}}}}
5143
- *{{box-sizing:border-box}}html,body{{max-width:100%;overflow-x:hidden}}body{{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif}}main{{width:min(1120px,calc(100% - 32px));margin:24px auto}}.hero,.card,.metric,.verify,details{{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px}}.hero h1{{margin:4px 0}}.metrics,.groups{{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin:10px 0}}.metric strong,.verify strong{{display:block;font-size:17px}}.verify small{{display:block;color:var(--muted)}}.risk{{display:grid;grid-template-columns:1fr 1fr;gap:10px}}.card{{margin:10px 0}}details{{margin:9px 0}}summary{{cursor:pointer;font-weight:700}}details>div{{overflow:auto}}table{{width:100%;border-collapse:collapse}}td,th{{padding:8px;border-bottom:1px solid var(--line);text-align:left}}code{{overflow-wrap:anywhere}}@media(max-width:600px){{main{{width:calc(100% - 18px);margin:9px auto}}.metrics,.groups,.risk{{grid-template-columns:1fr}}}}
5144
- </style></head><body><main>
5145
- <section class="hero"><small>HARNESS · 管理结论</small><h1>{esc(summary.get("changeName"))}</h1>
5146
- <p>{esc(summary.get("businessGoal") or "未记录业务目标")}</p>
5147
- <strong title="{esc(current.get("status"))}">{esc(status_label(current.get("status")))}</strong>
5148
- <p id="finalStatusReasons">{esc(" · ".join(str(item) for item in seq(current.get("reasons"))))}</p>
5149
- {"<p>归档意图:仅记录 · 未请求发布</p>" if record_only else ""}</section>
5150
- <section class="metrics"><article class="metric"><small>产品提交</small><strong><code>{esc(str(product_commit)[:10])}</code></strong></article>
5151
- <article class="metric"><small>验证概览</small><strong>{passed}/5 组通过</strong></article>
5152
- <article class="metric"><small>风险与动作</small><strong>{len(risks)} / {len(actions)}</strong></article>
5153
- <article class="metric"><small>全流程耗时</small><strong>{esc(timing.get("workflowWallClockMs") or "N/A")}</strong></article>
5154
- <article class="metric"><small>归档文件</small><strong>{esc(total_files_text or "N/A")}</strong></article></section>
5155
- <article class="card"><h2>验证概览</h2><div class="groups">{"".join(groups)}</div></article>
5156
- <article class="card"><h2>场景执行闭环</h2>
5157
- <p>结构化运行回执:{esc(scenario_status)}</p>
5158
- <p>未执行 / 未通过:{esc(", ".join(unexecuted_scenarios) or "无")}</p></article>
5159
- <article class="card"><h2>风险与动作</h2><div class="risk"><section><h3>当前风险</h3><ul>{risk_rows}</ul></section><section><h3>人工动作</h3><ul>{action_rows}</ul></section></div></article>
5160
- {release_card}
5161
- <details><summary>技术证据 · 时间守恒</summary><div><p>conservationDeltaMs={esc(timing.get("conservationDeltaMs") if "conservationDeltaMs" in timing else "N/A")}</p>
5162
- <p>workflowWallClock={esc(timing.get("workflowWallClockMs") if "workflowWallClockMs" in timing else "N/A")}</p>
5163
- <p>stageActiveExecution={esc(timing.get("stageActiveExecutionMs") if "stageActiveExecutionMs" in timing else "N/A")}</p>
5164
- <p>stageWallClockSpan={esc(timing.get("stageWallClockSpanMs") if "stageWallClockSpanMs" in timing else "N/A")}</p>
5165
- <p>reportCutoffAt={esc(timing.get("reportCutoffAt") or "N/A")}</p>
5166
- <p>远端 runner 成本:{esc(measurement_label(remote_totals.get("runnerMinutes") or remote.get("runnerMinutes")))}</p>
5167
- <p>新增制品字节:{esc(measurement_label(storage.get("bytesAdded")))}</p></div></details>
5168
- <details><summary>技术证据 · 发布与归档治理</summary><div>
5169
- <p>归档完整性:{esc(archive_integrity.get("code") or rec(summary.get("archiveManifest")).get("checksumStatus") or "N/A")}</p>
5170
- <p>归档持久性:{esc(durability_status)} · {esc(durability.get("risk") or "已通过持久介质回读校验")}</p>
5171
- <p>保留策略:{esc(durability.get("retentionPolicy") or "N/A")}</p>
5172
- <p>历史质量:未闭合尝试 {esc(timing.get("unclosedAttemptCount") if "unclosedAttemptCount" in timing else "N/A")}</p>
5173
- <p>投影状态:{esc(projection.get("code") or projection.get("mode") or "N/A")}</p></div></details>
5174
- <details><summary>技术证据 · 变更文件</summary><div><table><tbody>{file_rows}</tbody></table></div></details>
5175
- <details><summary>技术证据 · 命令</summary><div><table><tbody>{command_rows}</tbody></table></div></details>
5176
- <details><summary>技术元数据</summary><div><p>productCommit=<code>{esc(product_commit)}</code></p>
5177
- <p>schemaVersion={esc(summary.get("schemaVersion"))}</p></div></details>
5178
- </main></body></html>
5179
- """
5180
-
5181
-
5182
-
5183
- # P1 slim-files: final-summary.html is a regenerable presentation layer over
5184
- # summary-data.json. `finalize --no-html` skips local HTML rendering entirely
5185
- # (a remote platform can render from summary-data.json); render failures are
5186
- # downgraded to warnings instead of rolling back the archive.
5187
- RENDER_HTML_ENABLED = True
5188
-
5189
-
5190
- def render_final_summary(
5191
- change_dir: Path,
5192
- summary_path: Path,
5193
- ) -> dict[str, Any]:
5194
- """Render final-summary.html via Node; fall back to a Python renderer.
5195
-
5196
- Returns ``{ok, renderer, fallbackReason, out_path}``:
5197
- - Node success -> renderer="node".
5198
- - Node unavailable/timeout/non-zero/no-file -> Python fallback; success ->
5199
- renderer="python-fallback" (fallbackReason carries the node failure cause).
5200
- - Both fail or produce no file -> ok=False (caller downgrades to warning).
5201
- - Rendering disabled (--no-html) -> ok=True with renderer="skipped".
5202
- """
5203
- out_path = change_dir / "reports" / "final" / "final-summary.html"
5204
- if not RENDER_HTML_ENABLED:
5205
- return {
5206
- "ok": True,
5207
- "renderer": "skipped",
5208
- "fallbackReason": "",
5209
- "out_path": str(out_path),
5210
- "skipped": True,
5211
- }
5212
- project = find_project_root(change_dir)
5213
- node = resolve_node_path(project)
5214
- fallback_reason = ""
5215
-
5216
- if node and RENDER_SCRIPT.is_file():
5217
- out_path.parent.mkdir(parents=True, exist_ok=True)
5218
- try:
5219
- proc = subprocess.run(
5220
- [
5221
- node,
5222
- str(RENDER_SCRIPT),
5223
- "--summary",
5224
- str(summary_path),
5225
- "--out",
5226
- str(out_path),
5227
- ],
5228
- capture_output=True,
5229
- text=True,
5230
- encoding="utf-8",
5231
- errors="replace",
5232
- timeout=60,
5233
- check=False,
5234
- )
5235
- if proc.returncode == 0 and out_path.is_file():
5236
- return {
5237
- "ok": True,
5238
- "renderer": "node",
5239
- "fallbackReason": "",
5240
- "out_path": str(out_path),
5241
- }
5242
- fallback_reason = (
5243
- f"node render exit {proc.returncode}: "
5244
- f"{(proc.stderr or proc.stdout or '')[:200]}"
5245
- )
5246
- except (OSError, subprocess.TimeoutExpired) as exc:
5247
- fallback_reason = f"node render failed: {exc}"
5248
- else:
5249
- fallback_reason = (
5250
- "node unavailable" if not node else f"renderer missing: {RENDER_SCRIPT}"
5251
- )
5252
-
5253
- # Python fallback
5254
- try:
5255
- summary = read_json(summary_path)
5256
- html = render_fallback_html(summary)
5257
- out_path.parent.mkdir(parents=True, exist_ok=True)
5258
- out_path.write_text(html, encoding="utf-8", newline="\n")
5259
- except (OSError, json.JSONDecodeError, ValueError) as exc:
5260
- return {
5261
- "ok": False,
5262
- "renderer": "none",
5263
- "fallbackReason": f"{fallback_reason}; fallback failed: {exc}",
5264
- "out_path": str(out_path),
5265
- }
5266
- if out_path.is_file():
5267
- return {
5268
- "ok": True,
5269
- "renderer": "python-fallback",
5270
- "fallbackReason": fallback_reason,
5271
- "out_path": str(out_path),
5272
- }
5273
- return {
5274
- "ok": False,
5275
- "renderer": "none",
5276
- "fallbackReason": f"{fallback_reason}; fallback produced no file",
5277
- "out_path": str(out_path),
5278
- }
5279
-
5280
-
5281
- # ---------------------------------------------------------------------------
5282
- # validate
5283
- # ---------------------------------------------------------------------------
5284
4692
 
5285
4693
 
5286
4694
  def _manifest_self_stats(
@@ -5312,22 +4720,24 @@ def _append_finalize_failure_terminal(
5312
4720
  *,
5313
4721
  operation_id: str,
5314
4722
  ) -> None:
5315
- """Persist one failed finalize attempt on the authoritative change source."""
4723
+ """Record a blocked preparation without manufacturing an archive attempt."""
5316
4724
  if not authoritative_change_dir.is_dir():
5317
4725
  return
5318
4726
  try:
5319
4727
  append_event(
5320
4728
  authoritative_change_dir,
5321
4729
  phase="archive",
5322
- type_="phase.start",
5323
- note=f"finalize operation {operation_id} failed before publish",
4730
+ type_="phase.prepare.start",
4731
+ note="开始检查归档条件",
5324
4732
  )
5325
4733
  append_event(
5326
4734
  authoritative_change_dir,
5327
4735
  phase="archive",
5328
- type_="phase.end",
5329
- status="FAIL",
5330
- note=f"finalize operation {operation_id} discarded: {message}",
4736
+ type_="phase.prepare.end",
4737
+ status="BLOCKED",
4738
+ code="ARCHIVE_PREPARE_BLOCKED",
4739
+ message=f"归档条件未满足:{message}",
4740
+ note=f"归档操作 {operation_id} 未进入正式执行",
5331
4741
  )
5332
4742
  except OSError:
5333
4743
  pass
@@ -5570,256 +4980,57 @@ def validate_source_consistency(
5570
4980
  }
5571
4981
 
5572
4982
 
5573
- def validate_summary(
5574
- summary: dict[str, Any],
5575
- html_path: Path | None,
5576
- *,
5577
- render_skipped: bool = False,
5578
- ) -> dict[str, Any]:
5579
- """Validate final-summary covers summary-data key facts (in-process)."""
4983
+ def validate_summary_data(summary: dict[str, Any]) -> dict[str, Any]:
4984
+ """Validate canonical summary data without creating an HTML projection."""
5580
4985
  issues: list[dict[str, str]] = []
5581
-
5582
- change_id = str(summary.get("changeName") or "")
5583
- html = ""
5584
- if html_path and html_path.is_file():
5585
- try:
5586
- html = html_path.read_text(encoding="utf-8-sig")
5587
- except OSError as exc:
5588
- issues.append(
5589
- {
5590
- "code": "missing-final-report",
5591
- "severity": "error",
5592
- "message": f"cannot read final-summary: {exc}",
5593
- }
5594
- )
5595
- else:
5596
- # P1 slim-files: final-summary.html 是 summary-data.json 的可再生展示
5597
- # 投影,不再是硬产物(summary-data + manifest + adequacy 仍 fail-closed)。
5598
- # HTML 恒为 warning,不阻断归档收尾;平台端可按 summary-data 渲染。
5599
- _ = render_skipped # kept for call-site compatibility
5600
- issues.append(
5601
- {
5602
- "code": "missing-final-report",
5603
- "severity": "warning",
5604
- "message": "reports/final/final-summary.html not found",
5605
- }
5606
- )
5607
-
5608
- def has_text(needle: str) -> bool:
5609
- if not needle or needle == NOT_AVAILABLE:
5610
- return True
5611
- return needle in html
5612
-
5613
- # change id
5614
- if html and change_id and not has_text(change_id):
4986
+ verification = summary.get("verification") or {}
4987
+ verification = verification if isinstance(verification, dict) else {}
4988
+ unit = verification.get("unitTests") or {}
4989
+ api = verification.get("apiTests") or {}
4990
+ browser = verification.get("browserE2E") or {}
4991
+ unit = unit if isinstance(unit, dict) else {}
4992
+ api = api if isinstance(api, dict) else {}
4993
+ browser = browser if isinstance(browser, dict) else {}
4994
+ api_status = str(api.get("status") or "")
4995
+ browser_status = str(browser.get("status") or "")
4996
+ final_status = str(summary.get("finalStatus") or "")
4997
+ has_skip = api_status == "USER_SKIPPED" or browser_status in {
4998
+ "USER_SKIPPED",
4999
+ "BLOCKED",
5000
+ "BLOCKED_BY_ENV",
5001
+ "NOT_RUN",
5002
+ "PARTIAL",
5003
+ } or str(verification.get("dbCompatibility") or "") == "BLOCKED_BY_DBA"
5004
+ has_failed_verification = (
5005
+ int(unit.get("failures") or 0) > 0
5006
+ or int(unit.get("errors") or 0) > 0
5007
+ or int(api.get("failed") or 0) > 0
5008
+ or int(browser.get("failed") or 0) > 0
5009
+ or browser_status == "FAIL"
5010
+ )
5011
+ stage = summary.get("stageStatus") or {}
5012
+ stage = stage if isinstance(stage, dict) else {}
5013
+ has_failed_stage = any(str(value).upper() == "FAIL" for value in stage.values())
5014
+ if (has_skip or has_failed_verification or has_failed_stage) and final_status == "OK":
5615
5015
  issues.append(
5616
5016
  {
5617
- "code": "missing-change-id",
5017
+ "code": "status-contradiction",
5618
5018
  "severity": "error",
5619
- "message": f"final-summary missing change id '{change_id}'",
5019
+ "message": (
5020
+ "finalStatus is pure OK but skipped, blocked, or failed "
5021
+ "verification is present"
5022
+ ),
5620
5023
  }
5621
5024
  )
5622
-
5623
- # key commands — stock render-summary.mjs does not embed reportPipeline.commands.
5624
- # Require commands to be present in summary-data; HTML absence is warning only.
5625
- commands = (summary.get("reportPipeline") or {}).get("commands") or []
5626
- if html:
5627
- for cmd in commands[:8]:
5628
- c = str(cmd.get("command") or "").strip()
5629
- if not c:
5630
- continue
5631
- fragment = c if len(c) <= 60 else c[:60]
5632
- token = c.split()[-1] if c.split() else c
5633
- in_html = (
5634
- fragment in html
5635
- or _html_escape(fragment) in html
5636
- or (len(token) >= 4 and (token in html or _html_escape(token) in html))
5637
- )
5638
- if not in_html:
5639
- issues.append(
5640
- {
5641
- "code": "missing-command",
5642
- "severity": "warning",
5643
- "message": (
5644
- f"final-summary HTML omits command (renderer may not "
5645
- f"embed commands): {c}"
5646
- ),
5647
- }
5648
- )
5649
-
5650
- # verification
5651
- ver = summary.get("verification") or {}
5652
- unit = ver.get("unitTests") or {}
5653
- api = ver.get("apiTests") or {}
5654
- browser = ver.get("browserE2E") or {}
5655
- pass_rate = unit.get("passRate")
5656
- if pass_rate and pass_rate != NOT_AVAILABLE and str(pass_rate) not in html:
5657
- issues.append(
5658
- {
5659
- "code": "missing-verification",
5660
- "severity": "warning",
5661
- "message": f"unitTests.passRate '{pass_rate}' not in final-summary",
5662
- }
5663
- )
5664
- api_status = str(api.get("status") or "")
5665
- if api_status and api_status not in {"", NOT_AVAILABLE} and api_status not in html:
5666
- # Renderer shows api status; soft warning
5667
- if api_status in {"USER_SKIPPED", "BLOCKED_BY_DBA", "NOT_RUN", "PARTIAL"}:
5668
- issues.append(
5669
- {
5670
- "code": "missing-verification",
5671
- "severity": "warning",
5672
- "message": f"apiTests.status '{api_status}' not visible in final-summary",
5673
- }
5674
- )
5675
- browser_status = str(browser.get("status") or "")
5676
- if (
5677
- browser_status
5678
- and browser_status not in {"", NOT_AVAILABLE}
5679
- and browser_status not in html
5680
- ):
5681
- issues.append(
5682
- {
5683
- "code": "missing-verification",
5684
- "severity": "warning",
5685
- "message": (
5686
- f"browserE2E.status '{browser_status}' not visible "
5687
- "in final-summary"
5688
- ),
5689
- }
5690
- )
5691
-
5692
- # artifacts / summary-data path hints
5693
- am = summary.get("archiveManifest") or {}
5694
- total = am.get("totalArchiveFiles")
5695
- if total is not None and str(total) not in html:
5696
- issues.append(
5697
- {
5698
- "code": "missing-artifact",
5699
- "severity": "warning",
5700
- "message": "archiveManifest.totalArchiveFiles not reflected in final-summary",
5701
- }
5702
- )
5703
-
5704
- # risks / manual actions — empty arrays are OK (placeholder)
5705
- for risk in summary.get("knownRisks") or []:
5706
- # UT-016/RET-28: canonical projection — structured risk objects
5707
- # project to their message; str(dict) never matches rendered HTML.
5708
- if isinstance(risk, dict):
5709
- text = str(risk.get("message") or risk.get("summary") or "").strip()
5710
- else:
5711
- text = str(risk)
5712
- if text and text not in html and _html_escape(text) not in html:
5713
- issues.append(
5714
- {
5715
- "code": "missing-risk",
5716
- "severity": "warning",
5717
- "message": f"knownRisk not in final-summary: {text[:80]}",
5718
- }
5719
- )
5720
-
5721
- # status contradiction
5722
- final_status = str(summary.get("finalStatus") or "")
5723
- browser_conditional = {
5724
- "USER_SKIPPED",
5725
- "BLOCKED",
5726
- "BLOCKED_BY_ENV",
5727
- "NOT_RUN",
5728
- "PARTIAL",
5729
- }
5730
- has_skip = api_status == "USER_SKIPPED" or browser_status in browser_conditional or str(
5731
- ver.get("dbCompatibility") or ""
5732
- ) == "BLOCKED_BY_DBA"
5733
- has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
5734
- has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
5735
- has_fail_ver = (
5736
- has_fail_ver
5737
- or int(browser.get("failed") or 0) > 0
5738
- or browser_status == "FAIL"
5739
- )
5740
- stage = summary.get("stageStatus") or {}
5741
- has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
5742
-
5743
- if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
5744
- issues.append(
5745
- {
5746
- "code": "status-contradiction",
5747
- "severity": "error",
5748
- "message": (
5749
- "finalStatus is pure OK but USER_SKIPPED/BLOCKED_BY_DBA/"
5750
- "failed verification present"
5751
- ),
5752
- }
5753
- )
5754
- if has_skip and html:
5755
- if re.search(r">\s*OK\s*<", html) and "CONDITIONAL" not in html.upper():
5756
- if final_status != "CONDITIONAL_OK":
5757
- issues.append(
5758
- {
5759
- "code": "status-contradiction",
5760
- "severity": "error",
5761
- "message": "final-summary shows pure OK despite USER_SKIPPED/BLOCKED",
5762
- }
5763
- )
5764
- else:
5765
- # No HTML (and not render_skipped handled above): still check data-level status rules
5766
- ver = summary.get("verification") or {}
5767
- unit = ver.get("unitTests") or {}
5768
- api = ver.get("apiTests") or {}
5769
- browser = ver.get("browserE2E") or {}
5770
- api_status = str(api.get("status") or "")
5771
- browser_status = str(browser.get("status") or "")
5772
- final_status = str(summary.get("finalStatus") or "")
5773
- has_skip = api_status == "USER_SKIPPED" or browser_status in {
5774
- "USER_SKIPPED",
5775
- "BLOCKED",
5776
- "BLOCKED_BY_ENV",
5777
- "NOT_RUN",
5778
- "PARTIAL",
5779
- } or str(
5780
- ver.get("dbCompatibility") or ""
5781
- ) == "BLOCKED_BY_DBA"
5782
- has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
5783
- has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
5784
- has_fail_ver = (
5785
- has_fail_ver
5786
- or int(browser.get("failed") or 0) > 0
5787
- or browser_status == "FAIL"
5788
- )
5789
- stage = summary.get("stageStatus") or {}
5790
- has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
5791
- if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
5792
- issues.append(
5793
- {
5794
- "code": "status-contradiction",
5795
- "severity": "error",
5796
- "message": (
5797
- "finalStatus is pure OK but USER_SKIPPED/BLOCKED_BY_DBA/"
5798
- "failed verification present"
5799
- ),
5800
- }
5801
- )
5802
-
5803
- errors = [i for i in issues if i.get("severity") == "error"]
5804
- warnings = [i for i in issues if i.get("severity") != "error"]
5025
+ errors = [item for item in issues if item.get("severity") == "error"]
5805
5026
  return {
5806
- "ok": len(errors) == 0,
5027
+ "ok": not errors,
5807
5028
  "issues": issues,
5808
5029
  "error_count": len(errors),
5809
- "warning_count": len(warnings),
5030
+ "warning_count": len(issues) - len(errors),
5810
5031
  }
5811
5032
 
5812
5033
 
5813
- def _html_escape(text: str) -> str:
5814
- return (
5815
- text.replace("&", "&amp;")
5816
- .replace("<", "&lt;")
5817
- .replace(">", "&gt;")
5818
- .replace('"', "&quot;")
5819
- .replace("'", "&#39;")
5820
- )
5821
-
5822
-
5823
5034
  # ---------------------------------------------------------------------------
5824
5035
  # knowledge / service post-steps
5825
5036
  # ---------------------------------------------------------------------------
@@ -8125,32 +7336,12 @@ def cmd_finalize(
8125
7336
  payload["ok"] = False
8126
7337
  return 1, payload
8127
7338
 
8128
- # --- 6. render (Node, else Python fallback) ---
8129
- render_result = render_final_summary(work_dir, summary_path)
8130
- payload["steps"]["render"] = render_result
8131
- if not render_result.get("ok"):
8132
- # final-summary.html 是 summary-data.json 的可再生展示层:渲染失败
8133
- # 降级为 warning,归档继续(平台端可按 summary-data 重新渲染)。
8134
- msg = str(render_result.get("fallbackReason") or "render failed")
8135
- warnings.append(f"final-summary render failed (archive continues): {msg}")
8136
- renderer = render_result.get("renderer")
8137
- if renderer == "python-fallback" and render_result.get("fallbackReason"):
8138
- warnings.append(
8139
- f"node render unavailable; used python-fallback: "
8140
- f"{render_result.get('fallbackReason')}"
8141
- )
8142
-
8143
- html_path = work_dir / "reports" / "final" / "final-summary.html"
8144
-
8145
- # --- 7. renderer consistency (layer 2) ---
7339
+ # --- 6. canonical summary validation ---
8146
7340
  try:
8147
7341
  summary = read_json(summary_path)
8148
7342
  except (OSError, json.JSONDecodeError):
8149
7343
  pass
8150
- validate_result = validate_summary(
8151
- summary,
8152
- html_path if html_path.is_file() else None,
8153
- )
7344
+ validate_result = validate_summary_data(summary)
8154
7345
  payload["steps"]["validate"] = validate_result
8155
7346
  summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
8156
7347
  "issues"
@@ -8169,10 +7360,10 @@ def cmd_finalize(
8169
7360
  warnings.append(f"archive-meta write failed: {exc}")
8170
7361
  payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
8171
7362
 
8172
- # --- 9/10. final summary stats + render, then LAST manifest (IA-7) ---
8173
- # Post-manifest rewrites of covered bytes are forbidden. We update summary /
8174
- # HTML first, regenerate after-manifest last, then verify on-disk hashes.
8175
- # If summary/html must still change after that, they are excluded with reasons.
7363
+ # --- 9/10. final summary stats, then LAST manifest (IA-7) ---
7364
+ # Post-manifest rewrites of covered bytes are forbidden. We update the
7365
+ # summary first, regenerate after-manifest last, then verify on-disk hashes.
7366
+ # If summary must still change after that, it is excluded with a reason.
8176
7367
  summary = read_json(summary_path)
8177
7368
  summary["archiveManifest"] = {
8178
7369
  "movedFiles": 0,
@@ -8181,14 +7372,8 @@ def cmd_finalize(
8181
7372
  "checksumStatus": "PENDING",
8182
7373
  }
8183
7374
  write_json(summary_path, summary)
8184
- render_result = render_final_summary(work_dir, summary_path)
8185
- if not render_result.get("ok"):
8186
- warnings.append(
8187
- "final summary re-render failed (archive continues): "
8188
- f"{render_result.get('fallbackReason') or render_result.get('error')}"
8189
- )
8190
7375
  summary = read_json(summary_path)
8191
- validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
7376
+ validate_result = validate_summary_data(summary)
8192
7377
  payload["steps"]["validate"] = validate_result
8193
7378
  summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
8194
7379
  "issues"
@@ -8214,25 +7399,15 @@ def cmd_finalize(
8214
7399
  "reports/final/summary-data.json": (
8215
7400
  "archiveManifest stats written after coverage snapshot"
8216
7401
  ),
8217
- "reports/final/final-summary.html": (
8218
- "re-rendered after coverage snapshot to display manifest stats"
8219
- ),
8220
7402
  },
8221
7403
  **_manifest_self_stats(work_dir, after_manifest, after_path),
8222
7404
  }
8223
7405
  write_json(summary_path, summary)
8224
- render_result = render_final_summary(work_dir, summary_path)
8225
- if not render_result.get("ok"):
8226
- warnings.append(
8227
- "post-manifest summary render failed (archive continues): "
8228
- f"{render_result.get('fallbackReason') or render_result.get('error')}"
8229
- )
8230
7406
  coverage = verify_manifest_byte_coverage(
8231
7407
  work_dir,
8232
7408
  after_manifest,
8233
7409
  exclude_paths=[
8234
7410
  "reports/final/summary-data.json",
8235
- "reports/final/final-summary.html",
8236
7411
  ],
8237
7412
  exclusion_reasons=summary["archiveManifest"]["exclusionReasons"],
8238
7413
  )
@@ -8288,12 +7463,8 @@ def cmd_finalize(
8288
7463
  ]
8289
7464
  payload["releaseEligible"] = summary["releaseEligible"]
8290
7465
  write_json(summary_path, summary)
8291
- # Re-render once more for checksumStatus display; remain excluded.
8292
- render_final_summary(work_dir, summary_path)
8293
7466
  summary = read_json(summary_path)
8294
- validate_result = validate_summary(
8295
- summary, html_path if html_path.is_file() else None
8296
- )
7467
+ validate_result = validate_summary_data(summary)
8297
7468
  payload["steps"]["validate"] = validate_result
8298
7469
  summary.setdefault("reportPipeline", {})["validationIssues"] = (
8299
7470
  validate_result.get("issues") or []
@@ -8407,7 +7578,6 @@ def cmd_finalize(
8407
7578
  shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
8408
7579
  work_dir = archive_dir
8409
7580
  summary_path = work_dir / "reports" / "final" / "summary-data.json"
8410
- html_path = work_dir / "reports" / "final" / "final-summary.html"
8411
7581
  payload["steps"]["move"] = {"ok": True, "to": str(archive_dir)}
8412
7582
  except OSError as exc:
8413
7583
  payload["error"] = f"publish failed: {exc}"
@@ -8508,7 +7678,6 @@ def cmd_finalize(
8508
7678
  payload["finalStatus"] = "OK"
8509
7679
  payload["warnings"] = warnings
8510
7680
  payload["summary_data"] = str(summary_path)
8511
- payload["final_summary"] = str(html_path) if html_path.is_file() else None
8512
7681
  return 0, payload
8513
7682
 
8514
7683
 
@@ -8845,6 +8014,59 @@ def _deterministic_zip_info(path: str) -> zipfile.ZipInfo:
8845
8014
  return info
8846
8015
 
8847
8016
 
8017
+ _WINDOWS_LOCAL_PATH_RE = re.compile(
8018
+ r"\b[A-Za-z]:\\(?:[^\s<>:\"|?*]+\\)*[^\s<>:\"|?*]*"
8019
+ )
8020
+
8021
+
8022
+ def _archive_remote_json_value(value: Any) -> Any:
8023
+ """Remove machine-local absolute paths from the remote JSON projection."""
8024
+ if isinstance(value, dict):
8025
+ return {
8026
+ key: _archive_remote_json_value(item)
8027
+ for key, item in value.items()
8028
+ }
8029
+ if isinstance(value, list):
8030
+ return [_archive_remote_json_value(item) for item in value]
8031
+ if not isinstance(value, str):
8032
+ return value
8033
+ if re.match(r"^[A-Za-z]:[\\/]", value) or value.startswith("\\\\"):
8034
+ return "<local-path>"
8035
+ return _WINDOWS_LOCAL_PATH_RE.sub("<local-path>", value)
8036
+
8037
+
8038
+ def _archive_remote_json_bytes(raw: bytes) -> bytes:
8039
+ value = json.loads(raw)
8040
+ projected = _archive_remote_json_value(value)
8041
+ return (
8042
+ json.dumps(
8043
+ projected,
8044
+ ensure_ascii=False,
8045
+ sort_keys=True,
8046
+ separators=(",", ":"),
8047
+ )
8048
+ + "\n"
8049
+ ).encode("utf-8")
8050
+
8051
+
8052
+ def resolve_npx_launcher() -> list[str]:
8053
+ """Resolve npx without asking CreateProcess to execute a Windows .CMD shim."""
8054
+ npx = shutil.which("npx")
8055
+ if not npx:
8056
+ raise FileNotFoundError("npx is not installed or not available on PATH")
8057
+ if os.name != "nt":
8058
+ return [npx]
8059
+
8060
+ node = shutil.which("node")
8061
+ if not node:
8062
+ raise FileNotFoundError("node is required to launch npx on Windows")
8063
+ npx_path = Path(npx)
8064
+ npx_cli = npx_path.parent / "node_modules" / "npm" / "bin" / "npx-cli.js"
8065
+ if not npx_cli.is_file():
8066
+ raise FileNotFoundError(f"npx-cli.js not found beside Node.js: {npx_cli}")
8067
+ return [node, str(npx_cli)]
8068
+
8069
+
8848
8070
  def build_archive_package(
8849
8071
  project_root: Path,
8850
8072
  archive_dir: Path,
@@ -8875,7 +8097,7 @@ def build_archive_package(
8875
8097
  # All semantic archive inputs are text and must round-trip as UTF-8.
8876
8098
  raw.decode("utf-8")
8877
8099
  if media_type == "application/json":
8878
- json.loads(raw)
8100
+ raw = _archive_remote_json_bytes(raw)
8879
8101
  content_by_path[package_path] = raw
8880
8102
  entries.append(
8881
8103
  {
@@ -9104,8 +8326,21 @@ def auto_push_archive_core(
9104
8326
 
9105
8327
  # The published CLI owns credentials, project binding, durable receipt checks,
9106
8328
  # and retries. Generic `push` is intentionally not used for archive bytes.
8329
+ try:
8330
+ launcher = resolve_npx_launcher()
8331
+ except OSError as exc:
8332
+ reason_code = "ARCHIVE_UPLOAD_DEFERRED"
8333
+ try:
8334
+ persist_receipt(upload_status="pending", reason_code=reason_code)
8335
+ except OSError:
8336
+ pass
8337
+ return {
8338
+ **base_result,
8339
+ "reasonCode": reason_code,
8340
+ "warning": f"归档自动上传已延后:{exc}",
8341
+ }
9107
8342
  command = [
9108
- "npx",
8343
+ *launcher,
9109
8344
  "--yes",
9110
8345
  "hunter-harness",
9111
8346
  "archive",
@@ -9359,13 +8594,7 @@ def cmd_replay(
9359
8594
  # Collect without writing into the archive
9360
8595
  summary = collect_summary_data(archive_dir, write=False, for_replay=True)
9361
8596
 
9362
- html_path = archive_dir / "reports" / "final" / "final-summary.html"
9363
- render_skipped = not html_path.is_file()
9364
- validate_result = validate_summary(
9365
- summary,
9366
- html_path if html_path.is_file() else None,
9367
- render_skipped=render_skipped,
9368
- )
8597
+ validate_result = validate_summary_data(summary)
9369
8598
  summary.setdefault("reportPipeline", {})["validationIssues"] = (
9370
8599
  validate_result.get("issues") or []
9371
8600
  )
@@ -9462,9 +8691,6 @@ def cmd_certify_local_cli(args: argparse.Namespace) -> int:
9462
8691
 
9463
8692
 
9464
8693
  def cmd_finalize_cli(args: argparse.Namespace) -> int:
9465
- global RENDER_HTML_ENABLED
9466
- if getattr(args, "no_html", False):
9467
- RENDER_HTML_ENABLED = False
9468
8694
  change_dir = resolve_path(args.change_dir)
9469
8695
  archive_root = resolve_path(args.archive_root)
9470
8696
  durable_root = (
@@ -9516,50 +8742,12 @@ def cmd_replay_cli(args: argparse.Namespace) -> int:
9516
8742
  return code
9517
8743
 
9518
8744
 
9519
- def _render_html_to(
9520
- change_dir: Path,
9521
- summary_path: Path,
9522
- out_path: Path,
9523
- ) -> dict[str, Any]:
9524
- """Render summary HTML to an arbitrary path (node first, python fallback).
9525
-
9526
- Unlike ``render_final_summary`` this never touches the canonical
9527
- ``reports/final/final-summary.html`` — repair renders into staging.
9528
- """
9529
- project = find_project_root(change_dir)
9530
- node = resolve_node_path(project)
9531
- out_path.parent.mkdir(parents=True, exist_ok=True)
9532
- if node and RENDER_SCRIPT.is_file():
9533
- try:
9534
- proc = subprocess.run(
9535
- [node, str(RENDER_SCRIPT), "--summary", str(summary_path), "--out", str(out_path)],
9536
- capture_output=True,
9537
- text=True,
9538
- encoding="utf-8",
9539
- errors="replace",
9540
- timeout=60,
9541
- check=False,
9542
- )
9543
- if proc.returncode == 0 and out_path.is_file():
9544
- return {"ok": True, "renderer": "node", "out_path": str(out_path)}
9545
- except (OSError, subprocess.TimeoutExpired):
9546
- pass
9547
- try:
9548
- summary = read_json(summary_path)
9549
- html = render_fallback_html(summary)
9550
- out_path.write_text(html, encoding="utf-8", newline="\n")
9551
- except (OSError, json.JSONDecodeError, ValueError) as exc:
9552
- return {"ok": False, "renderer": "none", "error": str(exc)}
9553
- return {"ok": True, "renderer": "python-fallback", "out_path": str(out_path)}
9554
-
9555
-
9556
8745
  def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
9557
8746
  """Versioned repair (task 11 / RET-40).
9558
8747
 
9559
- Re-collect a candidate from the frozen sources, run both validators, and
9560
- only then write an immutable ``derived/v<N>/`` plus a repair record. The
9561
- original summary/HTML/manifest is never overwritten; the authoritative
9562
- pointer moves only when both validators pass.
8748
+ Re-collect a candidate from the frozen sources, validate its source and
8749
+ canonical data, then write an immutable ``derived/v<N>/`` plus a repair
8750
+ record. The original summary and manifest are never overwritten.
9563
8751
  """
9564
8752
  payload: dict[str, Any] = {
9565
8753
  "ok": False,
@@ -9596,25 +8784,18 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
9596
8784
  payload["error"] = f"repair collect failed: {exc}"
9597
8785
  return 1, payload
9598
8786
 
9599
- # 2. stage outside the archive; run both validators on the candidate.
8787
+ # 2. stage outside the archive; validate canonical data only.
9600
8788
  staging = Path(tempfile.mkdtemp(prefix="harness-repair-"))
9601
8789
  try:
9602
8790
  staged_summary = staging / "summary-data.json"
9603
8791
  write_json(staged_summary, candidate)
9604
8792
  source_result = validate_source_consistency(archive_dir, candidate)
9605
- render_result = _render_html_to(
9606
- archive_dir, staged_summary, staging / "final-summary.html"
9607
- )
9608
- staged_html = staging / "final-summary.html"
9609
- renderer_result = validate_summary(
9610
- candidate,
9611
- staged_html if staged_html.is_file() else None,
9612
- )
8793
+ summary_result = validate_summary_data(candidate)
9613
8794
  payload["validators"] = {
9614
8795
  "source": source_result,
9615
- "renderer": renderer_result,
8796
+ "summary": summary_result,
9616
8797
  }
9617
- if not (source_result.get("ok") and renderer_result.get("ok")):
8798
+ if not (source_result.get("ok") and summary_result.get("ok")):
9618
8799
  payload["error"] = "repair validators failed; derived version not written"
9619
8800
  return 1, payload
9620
8801
 
@@ -9637,8 +8818,6 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
9637
8818
  target_manifest = staged_version_dir / manifest_name
9638
8819
  shutil.copy2(source_manifest, target_manifest)
9639
8820
  frozen_manifest_hashes[manifest_name] = "sha256:" + sha256_file(target_manifest)
9640
- if staged_html.is_file():
9641
- shutil.copy2(staged_html, staged_version_dir / "final-summary.html")
9642
8821
  record = {
9643
8822
  "version": version,
9644
8823
  "createdAt": now_iso(),
@@ -9647,7 +8826,7 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
9647
8826
  "frozenManifestHashes": frozen_manifest_hashes,
9648
8827
  "validators": {
9649
8828
  "source": {"ok": bool(source_result.get("ok")), "issues": source_result.get("issues") or []},
9650
- "renderer": {"ok": bool(renderer_result.get("ok")), "issues": renderer_result.get("issues") or []},
8829
+ "summary": {"ok": bool(summary_result.get("ok")), "issues": summary_result.get("issues") or []},
9651
8830
  },
9652
8831
  }
9653
8832
  write_json(staged_version_dir / "repair-record.json", record)
@@ -9769,11 +8948,6 @@ def build_parser() -> argparse.ArgumentParser:
9769
8948
  default="release-candidate",
9770
8949
  help="release-candidate enforces candidate evidence; record-only archives facts without release eligibility",
9771
8950
  )
9772
- p_fin.add_argument(
9773
- "--no-html",
9774
- action="store_true",
9775
- help="skip local final-summary.html rendering (summary-data.json stays canonical)",
9776
- )
9777
8951
  p_fin.add_argument("--json", action="store_true", default=True)
9778
8952
  p_fin.set_defaults(func=cmd_finalize_cli)
9779
8953