@hunter-harness/workflow-harness 0.4.0 → 0.4.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 (386) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
  16. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
  33. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
  34. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
  50. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
  51. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
  67. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
  68. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
  69. package/harness/bundles/general/pi/.harness-build.json +6 -0
  70. package/harness/bundles/general/pi/CONTEXT.md +98 -0
  71. package/harness/bundles/general/pi/README.md +478 -0
  72. package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
  73. package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
  74. package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
  75. package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
  76. package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
  77. package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
  78. package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
  79. package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
  80. package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
  81. package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
  82. package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
  83. package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
  84. package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
  85. package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  86. package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
  87. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
  88. package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
  89. package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
  90. package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  91. package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  92. package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  93. package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  94. package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
  95. package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  96. package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
  97. package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  98. package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
  99. package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
  100. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
  101. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
  102. package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
  103. package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
  104. package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
  105. package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
  106. package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
  107. package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
  108. package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
  109. package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
  110. package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
  111. package/harness/bundles/general/pi/harness-review/reference.md +114 -0
  112. package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
  113. package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
  114. package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
  115. package/harness/bundles/general/pi/harness-run/reference.md +886 -0
  116. package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
  117. package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
  118. package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
  119. package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
  120. package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
  121. package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
  122. package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
  123. package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
  124. package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
  125. package/harness/bundles/general/pi/harness-test/reference.md +880 -0
  126. package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  127. package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
  128. package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
  129. package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  130. package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
  131. package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
  132. package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
  133. package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
  134. package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
  135. package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
  136. package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
  137. package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
  138. package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
  139. package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
  140. package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
  141. package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
  142. package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
  143. package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
  144. package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
  145. package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
  146. package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
  147. package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
  148. package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
  149. package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
  150. package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
  151. package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
  152. package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
  153. package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
  154. package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
  155. package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
  156. package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
  157. package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
  158. package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
  159. package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
  160. package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
  161. package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
  162. package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
  163. package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
  164. package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
  165. package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
  166. package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
  167. package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
  168. package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
  169. package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
  170. package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
  171. package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
  172. package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
  173. package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
  174. package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
  175. package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
  176. package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
  177. package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
  178. package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
  179. package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
  180. package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
  181. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  182. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  184. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  185. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
  186. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  190. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  192. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  193. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  194. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  195. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  196. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  197. package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
  198. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
  199. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  200. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  201. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  202. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  203. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  204. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
  205. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  206. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  207. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  208. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  209. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  210. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  212. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  213. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  214. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  215. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
  217. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
  218. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  219. package/harness/bundles/java/codex/.harness-build.json +1 -1
  220. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  221. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  222. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  223. package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  225. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  226. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  227. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  228. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  229. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  230. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  231. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  232. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  233. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  234. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  235. package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
  236. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
  237. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
  238. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  239. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  244. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  246. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  248. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  254. package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
  255. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
  256. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
  257. package/harness/bundles/java/pi/.harness-build.json +6 -0
  258. package/harness/bundles/java/pi/CONTEXT.md +98 -0
  259. package/harness/bundles/java/pi/README.md +478 -0
  260. package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
  261. package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
  262. package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
  263. package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
  264. package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
  265. package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
  266. package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
  267. package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
  268. package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
  269. package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
  270. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
  271. package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
  272. package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
  273. package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
  274. package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
  275. package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
  276. package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  277. package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
  278. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
  279. package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
  280. package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
  281. package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  282. package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  283. package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  284. package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  285. package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
  286. package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  287. package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
  288. package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  289. package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
  290. package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
  291. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
  292. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
  293. package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
  294. package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
  295. package/harness/bundles/java/pi/harness-package/reference.md +210 -0
  296. package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
  297. package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
  298. package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
  299. package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
  300. package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
  301. package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
  302. package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
  303. package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
  304. package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
  305. package/harness/bundles/java/pi/harness-review/reference.md +114 -0
  306. package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
  307. package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
  308. package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
  309. package/harness/bundles/java/pi/harness-run/reference.md +820 -0
  310. package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
  311. package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
  312. package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
  313. package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
  314. package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
  315. package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
  316. package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
  317. package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
  318. package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
  319. package/harness/bundles/java/pi/harness-test/reference.md +792 -0
  320. package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  321. package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
  322. package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
  323. package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  324. package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
  325. package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
  326. package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
  327. package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
  328. package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
  329. package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
  330. package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
  331. package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
  332. package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
  333. package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
  334. package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
  335. package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
  336. package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
  337. package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
  338. package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
  339. package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
  340. package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
  341. package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
  342. package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
  343. package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
  344. package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
  345. package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
  346. package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
  347. package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
  348. package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
  349. package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
  350. package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
  351. package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
  352. package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
  353. package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
  354. package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
  355. package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
  356. package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
  357. package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
  358. package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
  359. package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
  360. package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
  361. package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
  362. package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
  363. package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
  364. package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
  365. package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
  366. package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
  367. package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
  368. package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
  369. package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
  370. package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
  371. package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
  372. package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
  373. package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
  374. package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
  375. package/harness/manifests/general/claude-code.json +17 -17
  376. package/harness/manifests/general/codebuddy.json +17 -17
  377. package/harness/manifests/general/codex.json +17 -17
  378. package/harness/manifests/general/cursor.json +17 -17
  379. package/harness/manifests/general/pi.json +475 -0
  380. package/harness/manifests/java/claude-code.json +19 -19
  381. package/harness/manifests/java/codebuddy.json +19 -19
  382. package/harness/manifests/java/codex.json +19 -19
  383. package/harness/manifests/java/cursor.json +19 -19
  384. package/harness/manifests/java/pi.json +499 -0
  385. package/hunter-workflow-family.json +2 -2
  386. package/package.json +1 -1
@@ -0,0 +1,3556 @@
1
+ #!/usr/bin/env python3
2
+ """Harness verification-ledger inputsHash fingerprint reuse (D6).
3
+
4
+ Subcommands:
5
+ hash — compute order-independent inputsHash for a file set
6
+ can-reuse — decide reuse / rerun / insufficient-evidence
7
+ record — write validation result + inputsHash/inputsFiles into ledger
8
+
9
+ Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import datetime as dt
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ import uuid
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ SCRIPTS_DIR = Path(__file__).resolve().parent
27
+ if str(SCRIPTS_DIR) not in sys.path:
28
+ sys.path.insert(0, str(SCRIPTS_DIR))
29
+
30
+ import harness_paths # noqa: E402
31
+ import harness_plan_finalize as hpf # noqa: E402
32
+ import harness_profile # noqa: E402
33
+
34
+
35
+ if hasattr(sys.stdout, "reconfigure"):
36
+ sys.stdout.reconfigure(encoding="utf-8")
37
+ if hasattr(sys.stderr, "reconfigure"):
38
+ sys.stderr.reconfigure(encoding="utf-8")
39
+
40
+
41
+ VERIFICATIONS = frozenset(
42
+ {
43
+ "compile",
44
+ "unitTest",
45
+ "unitTestFull",
46
+ "apiTest",
47
+ "browserTest",
48
+ "install",
49
+ "package",
50
+ "dbCompatibility",
51
+ }
52
+ )
53
+ STATUS_MAP = {
54
+ "ok": "OK",
55
+ "OK": "OK",
56
+ "fail": "FAIL",
57
+ "FAIL": "FAIL",
58
+ "not_run": "NOT_RUN",
59
+ "NOT_RUN": "NOT_RUN",
60
+ }
61
+ BROAD_SCOPES = frozenset({"module", "module-am", "full"})
62
+
63
+ # --- Ledger v2 (cluster 2) ---
64
+ LEDGER_VERSION = "harness-ledger-2"
65
+ DIFF_HASH_VERSION = "content-changeset-2"
66
+ TEST_TRACKING_REL = Path("evidence") / "test-tracking.json"
67
+ TEST_TRACKING_REASONS = frozenset({"tdd-created", "stale-test-repair", "test-updated"})
68
+ # Coverage lattice: a recorded verification's coverage rank must meet the
69
+ # verification's required rank. Prevents incremental evidence from satisfying
70
+ # a module/full gate (UT-015 / API-005).
71
+ COVERAGE_RANK = {"incremental": 0, "module": 1, "module-am": 2, "full": 3}
72
+ REQUIRED_COVERAGE = {
73
+ "unitTest": 0, # incremental suffices (scope checked separately)
74
+ "unitTestFull": 1, # module or broader
75
+ "compile": 1,
76
+ "apiTest": 1,
77
+ "browserTest": 1,
78
+ "install": 2, # module-am or broader
79
+ "package": 2,
80
+ "dbCompatibility": 1,
81
+ }
82
+ # git empty-tree object id (used as base fallback when no commit exists).
83
+ _EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
84
+
85
+
86
+ def resolve_verification_target(
87
+ verification: str,
88
+ project_root: Path | None,
89
+ ) -> dict[str, Any] | None:
90
+ """Resolve a built-in or project-declared profile-v3 verification target."""
91
+ if not isinstance(verification, str) or not re.fullmatch(
92
+ r"[A-Za-z][A-Za-z0-9_-]{0,63}",
93
+ verification,
94
+ ):
95
+ return None
96
+ if project_root is not None:
97
+ profile = harness_profile.load_profile(project_root)
98
+ graph = (
99
+ profile.get("verificationGraph")
100
+ if isinstance(profile, dict)
101
+ else None
102
+ )
103
+ targets = graph.get("targets") if isinstance(graph, dict) else None
104
+ target = targets.get(verification) if isinstance(targets, dict) else None
105
+ if isinstance(target, dict):
106
+ required = str(target.get("requiredCoverage") or "module")
107
+ if required not in COVERAGE_RANK:
108
+ return None
109
+ return {**target, "requiredCoverage": required}
110
+ if verification in VERIFICATIONS:
111
+ required_rank = REQUIRED_COVERAGE.get(verification, 1)
112
+ required = next(
113
+ (
114
+ name
115
+ for name, rank in COVERAGE_RANK.items()
116
+ if rank == required_rank
117
+ ),
118
+ "module",
119
+ )
120
+ return {
121
+ "commandKey": verification,
122
+ "dependsOn": [],
123
+ "requiredCoverage": required,
124
+ "candidate": verification == "unitTestFull",
125
+ "requiredCapabilities": [],
126
+ }
127
+ return None
128
+
129
+
130
+ def verification_target_identity(
131
+ verification: str,
132
+ target: dict[str, Any],
133
+ ) -> str:
134
+ payload = json.dumps(
135
+ {"verification": verification, "target": target},
136
+ ensure_ascii=False,
137
+ sort_keys=True,
138
+ separators=(",", ":"),
139
+ ).encode("utf-8")
140
+ return "sha256:" + hashlib.sha256(payload).hexdigest()
141
+
142
+
143
+ def _hash_text(value: str) -> str:
144
+ return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
145
+
146
+
147
+ def command_set_hash(command: str) -> str:
148
+ """Stable identity for the exact command set represented by one target."""
149
+ return _hash_text(canonical_command(command))
150
+
151
+
152
+ def canonical_command(command: str) -> str:
153
+ """Normalize supported launch wrappers without mixing runner notes into identity."""
154
+ value = re.sub(r"\s+", " ", str(command).strip())
155
+ value = re.sub(r"\s*\(safe runner\)\s*$", "", value, flags=re.I)
156
+ value = re.sub(r"^npx(?:\s+--no-install)?\s+", "", value, flags=re.I)
157
+ return value.strip()
158
+
159
+
160
+ def default_environment_hash(project_root: Path | None = None) -> str:
161
+ payload = {
162
+ "osName": os.name,
163
+ "platform": sys.platform,
164
+ "pythonImplementation": sys.implementation.name,
165
+ "projectFilesystem": "windows" if os.name == "nt" else "posix",
166
+ }
167
+ return _hash_text(json.dumps(payload, sort_keys=True, separators=(",", ":")))
168
+
169
+
170
+ def default_toolchain_hash(project_root: Path | None = None) -> str:
171
+ root = Path(project_root).resolve() if project_root is not None else None
172
+ manifests: list[dict[str, str]] = []
173
+ if root is not None:
174
+ for name in (
175
+ "package.json", "pyproject.toml", "pom.xml", "build.gradle",
176
+ "build.gradle.kts", "go.mod", "Cargo.toml",
177
+ ):
178
+ path = root / name
179
+ if path.is_file():
180
+ manifests.append({
181
+ "name": name,
182
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
183
+ })
184
+ payload = {
185
+ "python": sys.version.split()[0],
186
+ "implementation": sys.implementation.name,
187
+ "manifests": manifests,
188
+ }
189
+ return _hash_text(json.dumps(payload, sort_keys=True, separators=(",", ":")))
190
+
191
+
192
+ def frozen_ownership_check(change_dir: Path) -> dict[str, Any]:
193
+ """Reject evidence writes after a Run capsule's ownership scope changed."""
194
+ try:
195
+ state_root = Path(
196
+ harness_paths.resolve_state_dir_for_contract(change_dir)
197
+ ).resolve()
198
+ contract = harness_paths.load_change_contract(change_dir)
199
+ except (OSError, ValueError, json.JSONDecodeError):
200
+ return {"ok": True, "code": "OWNERSHIP_FREEZE_NOT_APPLICABLE"}
201
+ capsule_root = state_root / "runtime" / "phase-context"
202
+ if not capsule_root.is_dir():
203
+ return {"ok": True, "code": "OWNERSHIP_FREEZE_NOT_APPLICABLE"}
204
+ capsules: list[dict[str, Any]] = []
205
+ # capsule 文件名是 <phase>-<hash>.json;execute 合并(run/test→execute)后
206
+ # 新旧两批名字都可能躺在在途 change 里,按内容归一判定而不是按文件名。
207
+ for path in capsule_root.glob("*.json"):
208
+ try:
209
+ item = json.loads(path.read_text(encoding="utf-8-sig"))
210
+ except (OSError, json.JSONDecodeError):
211
+ continue
212
+ if (
213
+ isinstance(item, dict)
214
+ and harness_paths.resolve_phase_name(item.get("phase")) == "execute"
215
+ and not item.get("closedAt")
216
+ and _nonempty_str(item.get("ownershipHash"))
217
+ ):
218
+ capsules.append(item)
219
+ if not capsules:
220
+ return {"ok": True, "code": "OWNERSHIP_FREEZE_NOT_APPLICABLE"}
221
+ capsule = sorted(capsules, key=lambda item: str(item.get("createdAt") or ""))[-1]
222
+ current = ownership_hash(contract)
223
+ if capsule.get("ownershipHash") != current:
224
+ return {
225
+ "ok": False,
226
+ "code": "OWNERSHIP_CHANGED_BEFORE_VERIFICATION",
227
+ "message": (
228
+ "产品范围在 Run 启动后发生变化;请先通过受控范围更新重启 Run,"
229
+ "系统将只失效受影响的验证。"
230
+ ),
231
+ "storedOwnershipHash": capsule.get("ownershipHash"),
232
+ "currentOwnershipHash": current,
233
+ "runId": capsule.get("runId"),
234
+ }
235
+ return {"ok": True, "code": "OWNERSHIP_FROZEN"}
236
+
237
+
238
+ def product_tree_hash(project_root: Path | None) -> str | None:
239
+ """Git tree identity is content provenance; a commit SHA is not."""
240
+ if project_root is None:
241
+ return None
242
+ tree = _git_text(project_root, "rev-parse", "--verify", "HEAD^{tree}")
243
+ return "sha256:" + tree if _nonempty_str(tree) else None
244
+
245
+
246
+ def lock_hash(project_root: Path | None) -> str | None:
247
+ """Hash present dependency lockfiles, when the project exposes any."""
248
+ if project_root is None:
249
+ return None
250
+ names = (
251
+ "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml",
252
+ "yarn.lock", "Cargo.lock", "go.sum", "poetry.lock",
253
+ )
254
+ files = [project_root / name for name in names if (project_root / name).is_file()]
255
+ if not files:
256
+ return None
257
+ digest = hashlib.sha256()
258
+ for path in files:
259
+ digest.update(path.name.encode("utf-8"))
260
+ digest.update(b"\0")
261
+ digest.update(hashlib.sha256(path.read_bytes()).digest())
262
+ return "sha256:" + digest.hexdigest()
263
+
264
+
265
+ def canonical_reuse_key(entry: dict[str, Any]) -> dict[str, str]:
266
+ """Commit-independent verification identity used to authorize reuse."""
267
+ fields = (
268
+ "verification", "productTreeHash", "commandSetHash", "environmentHash",
269
+ "toolchainHash", "lockHash", "dbSchemaHash", "targetIdentity",
270
+ )
271
+ return {
272
+ field: str(entry.get(field)).strip()
273
+ for field in fields
274
+ if _nonempty_str(entry.get(field))
275
+ }
276
+
277
+
278
+ def now_iso() -> str:
279
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
280
+
281
+
282
+ def emit_json(payload: dict[str, Any], *, as_json: bool) -> None:
283
+ text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
284
+ if as_json:
285
+ sys.stdout.write(text)
286
+ else:
287
+ ok = payload.get("ok", True)
288
+ reuse = payload.get("reuse")
289
+ if reuse is not None:
290
+ sys.stdout.write(f"reuse={reuse} reason={payload.get('reason')}\n")
291
+ elif "diffHash" in payload:
292
+ sys.stdout.write(f"{payload['diffHash']}\n")
293
+ elif "inputsHash" in payload:
294
+ sys.stdout.write(f"{payload['inputsHash']}\n")
295
+ else:
296
+ sys.stdout.write(("ok" if ok else "error") + "\n")
297
+
298
+
299
+ def _compact_record_payload(payload: dict[str, Any]) -> dict[str, Any]:
300
+ """C5: record compact — ok/action/verification/status only."""
301
+ return {
302
+ "ok": payload.get("ok", True),
303
+ "action": payload.get("action"),
304
+ "verification": payload.get("verification"),
305
+ "status": payload.get("status"),
306
+ }
307
+
308
+
309
+ def _compact_can_reuse_payload(payload: dict[str, Any]) -> dict[str, Any]:
310
+ """C5: can-reuse compact — ok/reuse/code;拒绝复用时附带可执行的原因。
311
+
312
+ 只回 ok/reuse/code 时,调用方拿到裸 `{"reuse": false}` 无从判断该怎么办,
313
+ 只能再跑一次 `--verbose`——原因(profile 缺 verificationInputs、证据不完整等)
314
+ 是单行短文本,值得留在默认输出里。允许复用时保持原样精简。
315
+ """
316
+ compact = {
317
+ "ok": payload.get("ok", True),
318
+ "reuse": payload.get("reuse"),
319
+ "code": payload.get("code"),
320
+ }
321
+ if payload.get("reuse") is not True:
322
+ for key in ("reason", "executionNeed", "detail"):
323
+ value = payload.get(key)
324
+ if value not in (None, "", [], {}):
325
+ compact[key] = value
326
+ return compact
327
+
328
+
329
+ def emit_compact_or_verbose(
330
+ payload: dict[str, Any],
331
+ *,
332
+ as_json: bool,
333
+ verbose: bool,
334
+ compact_fn,
335
+ ) -> None:
336
+ """Emit compact payload by default; full payload when --verbose."""
337
+ out = payload if verbose else compact_fn(payload)
338
+ emit_json(out, as_json=as_json)
339
+
340
+
341
+ def emit_error(
342
+ message: str,
343
+ *,
344
+ as_json: bool,
345
+ code: int = 1,
346
+ error_code: str | None = None,
347
+ extra: dict[str, Any] | None = None,
348
+ ) -> int:
349
+ payload: dict[str, Any] = {"ok": False, "error": message}
350
+ if error_code:
351
+ payload["code"] = error_code
352
+ if extra:
353
+ payload.update(extra)
354
+ if as_json:
355
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
356
+ else:
357
+ sys.stderr.write(f"error: {message}\n")
358
+ return code
359
+
360
+
361
+ def resolve_path(raw: str) -> Path:
362
+ return Path(raw).expanduser().resolve()
363
+
364
+
365
+ def parse_files_arg(raw: str | None) -> list[str]:
366
+ if raw is None or not str(raw).strip():
367
+ return []
368
+ parts = [p.strip() for p in str(raw).split(",")]
369
+ return [p for p in parts if p]
370
+
371
+
372
+ def parse_files_manifest(raw: str | None) -> list[str]:
373
+ if raw is None or not str(raw).strip():
374
+ return []
375
+ path = Path(str(raw)).expanduser().resolve()
376
+ if not path.is_file():
377
+ raise FileNotFoundError(f"files manifest not found: {path}")
378
+ return [
379
+ line.strip()
380
+ for line in path.read_text(encoding="utf-8-sig").splitlines()
381
+ if line.strip() and not line.lstrip().startswith("#")
382
+ ]
383
+
384
+
385
+ class ProjectRootRequiredError(ValueError):
386
+ """Raised when relative verification inputs have no stable project basis."""
387
+
388
+
389
+ def resolve_input_files(
390
+ files: list[str], project_root: Path | None
391
+ ) -> list[str]:
392
+ if project_root is None:
393
+ return files
394
+ root = project_root.expanduser().resolve()
395
+ resolved: list[str] = []
396
+ for raw in files:
397
+ candidate = Path(raw).expanduser()
398
+ candidate = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
399
+ try:
400
+ candidate.relative_to(root)
401
+ except ValueError as exc:
402
+ raise ValueError(
403
+ f"INPUT_OUTSIDE_PROJECT: {candidate} is outside {root}"
404
+ ) from exc
405
+ resolved.append(str(candidate))
406
+ return resolved
407
+
408
+
409
+ def input_files_from_args(args: argparse.Namespace) -> tuple[list[str], Path | None]:
410
+ project_raw = getattr(args, "project", None)
411
+ project_root = (
412
+ Path(str(project_raw)).expanduser().resolve() if project_raw else None
413
+ )
414
+ files = parse_files_arg(getattr(args, "files", None))
415
+ manifest = parse_files_manifest(getattr(args, "files_from", None))
416
+ if files and manifest:
417
+ raise ValueError("use only one of --files and --files-from")
418
+ selected = files or manifest
419
+ if project_root is None and any(
420
+ not Path(raw).expanduser().is_absolute() for raw in selected
421
+ ):
422
+ raise ProjectRootRequiredError(
423
+ "relative verification inputs require --project so their identity "
424
+ "does not depend on the caller's current directory"
425
+ )
426
+ return resolve_input_files(selected, project_root), project_root
427
+
428
+
429
+ def infer_execution_project_root(change_dir: Path) -> Path | None:
430
+ """Resolve the tree under test from trusted change metadata.
431
+
432
+ Worktree changes keep their durable contract under the main checkout while
433
+ product files live in the execution worktree. Requiring every caller to
434
+ reconstruct that split caused ledger commands to hash the main checkout or
435
+ fail repeatedly. Prefer the persisted execution root and otherwise leave
436
+ legacy callers unchanged.
437
+ """
438
+ change_dir = change_dir.expanduser().resolve()
439
+ project_root = next(
440
+ (ancestor.parent for ancestor in change_dir.parents if ancestor.name == ".harness"),
441
+ None,
442
+ )
443
+ if project_root is None:
444
+ return None
445
+
446
+ def git_common_dir(root: Path) -> Path | None:
447
+ process = subprocess.run(
448
+ ["git", "-C", str(root), "rev-parse", "--path-format=absolute", "--git-common-dir"],
449
+ text=True,
450
+ encoding="utf-8",
451
+ errors="replace",
452
+ stdout=subprocess.PIPE,
453
+ stderr=subprocess.PIPE,
454
+ check=False,
455
+ )
456
+ if process.returncode != 0 or not process.stdout.strip():
457
+ return None
458
+ raw = Path(process.stdout.strip()).expanduser()
459
+ return raw.resolve() if raw.is_absolute() else (root / raw).resolve()
460
+
461
+ project_common_dir = git_common_dir(project_root)
462
+
463
+ def trusted_worktree(candidate: Path) -> Path | None:
464
+ if not candidate.is_dir():
465
+ return None
466
+ process = subprocess.run(
467
+ ["git", "-C", str(candidate), "rev-parse", "--show-toplevel"],
468
+ text=True,
469
+ encoding="utf-8",
470
+ errors="replace",
471
+ stdout=subprocess.PIPE,
472
+ stderr=subprocess.PIPE,
473
+ check=False,
474
+ )
475
+ if process.returncode != 0 or not process.stdout.strip():
476
+ return None
477
+ top_level = Path(process.stdout.strip()).expanduser().resolve()
478
+ if top_level != candidate:
479
+ return None
480
+ # Origin URL + root commit identifies a repository lineage, not a
481
+ # linked checkout. Independent clones can share both. A real linked
482
+ # worktree must share the exact Git common directory with the main tree.
483
+ if project_common_dir is None or git_common_dir(top_level) != project_common_dir:
484
+ return None
485
+ return top_level
486
+
487
+ candidates = (
488
+ (
489
+ change_dir / "meta" / "change-context.json",
490
+ ("worktreePath", "path", "worktreeRoot"),
491
+ ),
492
+ (
493
+ change_dir / "meta" / "worktree.json",
494
+ ("worktreePath", "path", "worktreeRoot"),
495
+ ),
496
+ )
497
+ for metadata_path, keys in candidates:
498
+ if not metadata_path.is_file():
499
+ continue
500
+ try:
501
+ payload = json.loads(metadata_path.read_text(encoding="utf-8-sig"))
502
+ except (OSError, json.JSONDecodeError):
503
+ continue
504
+ if not isinstance(payload, dict):
505
+ continue
506
+ for key in keys:
507
+ raw = payload.get(key)
508
+ if not isinstance(raw, str) or not raw.strip():
509
+ continue
510
+ raw_path = Path(raw).expanduser()
511
+ candidate = (
512
+ raw_path.resolve()
513
+ if raw_path.is_absolute()
514
+ else (project_root / raw_path).resolve()
515
+ )
516
+ if key == "worktreeRoot" and candidate.name != change_dir.name:
517
+ nested = (candidate / change_dir.name).resolve()
518
+ trusted_nested = trusted_worktree(nested)
519
+ if trusted_nested is not None:
520
+ return trusted_nested
521
+ if trusted_worktree(candidate) is None:
522
+ continue
523
+ trusted_candidate = trusted_worktree(candidate)
524
+ if trusted_candidate is not None:
525
+ return trusted_candidate
526
+ return None
527
+
528
+
529
+ def declares_execution_worktree(change_dir: Path) -> bool:
530
+ """Return whether persisted metadata explicitly selects another checkout."""
531
+ for metadata_path in (
532
+ change_dir / "meta" / "change-context.json",
533
+ change_dir / "meta" / "worktree.json",
534
+ ):
535
+ if not metadata_path.is_file():
536
+ continue
537
+ try:
538
+ payload = json.loads(metadata_path.read_text(encoding="utf-8-sig"))
539
+ except (OSError, json.JSONDecodeError):
540
+ continue
541
+ if isinstance(payload, dict) and any(
542
+ isinstance(payload.get(key), str) and str(payload[key]).strip()
543
+ for key in ("worktreePath", "path", "worktreeRoot")
544
+ ):
545
+ return True
546
+ return False
547
+
548
+
549
+ def apply_inferred_project_root(args: argparse.Namespace, change_dir: Path) -> None:
550
+ """Fill an omitted --project without overriding an explicit caller choice."""
551
+ if getattr(args, "project", None):
552
+ return
553
+ inferred = infer_execution_project_root(change_dir)
554
+ if inferred is not None:
555
+ args.project = str(inferred)
556
+
557
+
558
+ def sha256_file(path: Path) -> str:
559
+ digest = hashlib.sha256()
560
+ with path.open("rb") as handle:
561
+ while True:
562
+ chunk = handle.read(1024 * 1024)
563
+ if not chunk:
564
+ break
565
+ digest.update(chunk)
566
+ return digest.hexdigest()
567
+
568
+
569
+ def compute_inputs_hash(
570
+ file_paths: list[str], *, project_root: Path | str | None = None
571
+ ) -> tuple[str, list[str]]:
572
+ """Per-file content sha256 → sort digests → hash again (order-independent)."""
573
+ root = (
574
+ Path(project_root).expanduser().resolve()
575
+ if project_root is not None
576
+ else None
577
+ )
578
+ by_path: dict[str, str] = {}
579
+ for raw in file_paths:
580
+ path = resolve_path(raw)
581
+ if not path.is_file():
582
+ raise FileNotFoundError(f"file not found: {raw}")
583
+ if root is not None:
584
+ try:
585
+ logical_path = path.relative_to(root).as_posix()
586
+ except ValueError as exc:
587
+ raise ValueError(
588
+ f"INPUT_OUTSIDE_PROJECT: {path} is outside {root}"
589
+ ) from exc
590
+ else:
591
+ logical_path = path.as_posix()
592
+ by_path[logical_path] = sha256_file(path)
593
+
594
+ # Stable file list for callers; bind every resolved path to its digest.
595
+ # Hashing only the content multiset let a path swap incorrectly reuse a
596
+ # verification result.
597
+ resolved_files_sorted = sorted(by_path.keys())
598
+
599
+ combined = hashlib.sha256()
600
+ for path in resolved_files_sorted:
601
+ combined.update(path.encode("utf-8"))
602
+ combined.update(b"\0")
603
+ combined.update(by_path[path].encode("ascii"))
604
+ combined.update(b"\n")
605
+ return f"sha256:{combined.hexdigest()}", resolved_files_sorted
606
+
607
+
608
+ def _git_bytes(args: list[str], cwd: Path) -> bytes:
609
+ """Run git, return raw stdout bytes. Raises RuntimeError on non-zero exit."""
610
+ proc = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True)
611
+ if proc.returncode != 0:
612
+ msg = proc.stderr.decode("utf-8", "replace").strip()
613
+ raise RuntimeError(f"git {' '.join(args)} failed: {msg}")
614
+ return proc.stdout
615
+
616
+
617
+ def _git_text(cwd: Path, *args: str) -> str | None:
618
+ """Run git, return stripped stdout; None on non-zero exit."""
619
+ proc = subprocess.run(
620
+ ["git", *args],
621
+ cwd=str(cwd),
622
+ capture_output=True,
623
+ text=True,
624
+ encoding="utf-8",
625
+ errors="replace",
626
+ check=False,
627
+ )
628
+ if proc.returncode != 0:
629
+ return None
630
+ return proc.stdout.strip()
631
+
632
+
633
+ def _root_commit(repo_root: Path) -> str | None:
634
+ out = _git_bytes(["rev-list", "--max-parents=0", "HEAD"], repo_root)
635
+ lines = [ln for ln in out.decode("utf-8", "replace").splitlines() if ln.strip()]
636
+ return lines[0] if lines else None
637
+
638
+
639
+ def _changed_paths(repo_root: Path, base: str | None) -> tuple[str, list[str]]:
640
+ """Commit-invariant change set: tracked files differing from base (working
641
+ tree) plus untracked files. Sorted by repo-relative path.
642
+
643
+ Working-tree content is unchanged by a checkpoint commit, so the change set
644
+ and its bytes are identical before/after commit (UT-011).
645
+ """
646
+ if not base:
647
+ base = _root_commit(repo_root) or _EMPTY_TREE
648
+ out = _git_bytes(["diff", "--name-only", "--no-renames", "-z", base], repo_root)
649
+ paths: set[str] = set()
650
+ for name in out.split(b"\x00"):
651
+ if name:
652
+ paths.add(name.decode("utf-8"))
653
+ out2 = _git_bytes(["ls-files", "--others", "--exclude-standard", "-z"], repo_root)
654
+ for name in out2.split(b"\x00"):
655
+ if name:
656
+ paths.add(name.decode("utf-8"))
657
+ return base, sorted(paths)
658
+
659
+
660
+ def _inside(path: Path, root: Path) -> bool:
661
+ try:
662
+ path.relative_to(root)
663
+ return True
664
+ except ValueError:
665
+ return False
666
+
667
+
668
+ def _tracked_test_contents(
669
+ repo_root: Path,
670
+ change_dir: Path | str | None,
671
+ ) -> tuple[dict[str, bytes], str | None]:
672
+ """Load exact test paths recorded by harness_test_guard.
673
+
674
+ The ledger validates the same security-critical manifest fields again so a
675
+ hand-edited manifest cannot silently widen the fingerprint or reuse stale
676
+ evidence. Missing manifests remain backward-compatible and contribute no
677
+ additional paths.
678
+ """
679
+ if change_dir is None:
680
+ return {}, None
681
+ candidate = Path(change_dir)
682
+ if not candidate.is_absolute():
683
+ candidate = repo_root / candidate
684
+ change_root = candidate.resolve()
685
+ main_root = harness_paths.resolve_main_project_root(repo_root)
686
+ if not (_inside(change_root, repo_root) or _inside(change_root, main_root)):
687
+ raise ValueError("TEST_TRACKING_CHANGE_DIR_OUTSIDE_PROJECT")
688
+ state_root = _state_dir(change_root)
689
+ manifest_path = (state_root / TEST_TRACKING_REL).resolve()
690
+ if not _inside(manifest_path, state_root):
691
+ raise ValueError("TEST_TRACKING_MANIFEST_OUTSIDE_CHANGE")
692
+ if not manifest_path.is_file():
693
+ return {}, None
694
+ try:
695
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
696
+ except (OSError, json.JSONDecodeError) as exc:
697
+ raise ValueError(f"TEST_TRACKING_MANIFEST_INVALID: {exc}") from exc
698
+ if isinstance(manifest, dict) and manifest.get("schemaVersion") == 2:
699
+ return _tracked_test_contents_v2(repo_root, manifest, manifest_path)
700
+ if (
701
+ not isinstance(manifest, dict)
702
+ or manifest.get("schemaVersion") != 1
703
+ or manifest.get("mode") != "force-track-touched"
704
+ or manifest.get("projectRoot") != str(repo_root)
705
+ ):
706
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID")
707
+ entries = manifest.get("files")
708
+ if not isinstance(entries, list) or not entries:
709
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID: EMPTY_FILES")
710
+
711
+ contents: dict[str, bytes] = {}
712
+ for item in entries:
713
+ if not isinstance(item, dict):
714
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID")
715
+ rel = item.get("path")
716
+ expected_hash = item.get("sha256")
717
+ if (
718
+ not isinstance(rel, str)
719
+ or not rel
720
+ or not isinstance(expected_hash, str)
721
+ or item.get("reason") not in TEST_TRACKING_REASONS
722
+ or type(item.get("ignored")) is not bool
723
+ or type(item.get("trackedBefore")) is not bool
724
+ ):
725
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID")
726
+ raw_path = Path(rel)
727
+ resolved = (repo_root / raw_path).resolve()
728
+ if raw_path.is_absolute() or not _inside(resolved, repo_root):
729
+ raise ValueError(f"TEST_TRACKING_PATH_OUTSIDE_PROJECT: {rel}")
730
+ normalized = resolved.relative_to(repo_root).as_posix()
731
+ if normalized != rel or not resolved.is_file():
732
+ raise ValueError(f"TEST_TRACKING_FILE_INVALID: {rel}")
733
+ content = resolved.read_bytes()
734
+ actual_hash = "sha256:" + hashlib.sha256(content).hexdigest()
735
+ if actual_hash != expected_hash:
736
+ raise ValueError(f"TEST_TRACKING_HASH_DRIFT: {rel}")
737
+ contents[rel] = content
738
+ return {rel: contents[rel] for rel in sorted(contents)}, str(manifest_path)
739
+
740
+
741
+ def _tracked_test_contents_v2(
742
+ repo_root: Path,
743
+ manifest: dict[str, Any],
744
+ manifest_path: Path,
745
+ ) -> tuple[dict[str, bytes], str | None]:
746
+ """Validate a schema-2 manifest: repositoryId equality + logical hashes."""
747
+ if manifest.get("mode") != "force-track-touched":
748
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID")
749
+ repository_id = manifest.get("repositoryId")
750
+ if not isinstance(repository_id, str) or not repository_id.startswith("sha256:"):
751
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID")
752
+ if repository_id != harness_paths.repository_identity(repo_root):
753
+ raise ValueError("TEST_TRACKING_REPOSITORY_MISMATCH")
754
+ entries = manifest.get("files")
755
+ if not isinstance(entries, list) or not entries:
756
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID: EMPTY_FILES")
757
+
758
+ contents: dict[str, bytes] = {}
759
+ for item in entries:
760
+ if not isinstance(item, dict):
761
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID")
762
+ rel = item.get("path")
763
+ expected = item.get("logicalHash") or item.get("binaryHash")
764
+ if (
765
+ not isinstance(rel, str)
766
+ or not rel
767
+ or not isinstance(expected, str)
768
+ or not (expected.startswith("gitblob:") or expected.startswith("sha256:"))
769
+ or item.get("reason") not in TEST_TRACKING_REASONS
770
+ or type(item.get("ignored")) is not bool
771
+ or not isinstance(item.get("introducedBy"), str)
772
+ or not isinstance(item.get("touchedBy"), list)
773
+ or item.get("commitScope") not in ("current-change", "foreign-change")
774
+ ):
775
+ raise ValueError("TEST_TRACKING_MANIFEST_INVALID")
776
+ raw_path = Path(rel)
777
+ resolved = (repo_root / raw_path).resolve()
778
+ if raw_path.is_absolute() or not _inside(resolved, repo_root):
779
+ raise ValueError(f"TEST_TRACKING_PATH_OUTSIDE_PROJECT: {rel}")
780
+ normalized = resolved.relative_to(repo_root).as_posix()
781
+ if normalized != rel or not resolved.is_file():
782
+ raise ValueError(f"TEST_TRACKING_FILE_INVALID: {rel}")
783
+ content = resolved.read_bytes()
784
+ actual = _logical_file_hash(repo_root, rel, content)
785
+ if actual != expected:
786
+ raise ValueError(f"TEST_TRACKING_HASH_DRIFT: {rel}")
787
+ contents[rel] = content
788
+ return {rel: contents[rel] for rel in sorted(contents)}, str(manifest_path)
789
+
790
+
791
+ def _logical_file_hash(repo_root: Path, rel: str, content: bytes) -> str:
792
+ """Mirror of harness_test_guard.logical_file_hash for validation."""
793
+ attr = subprocess.run(
794
+ ["git", "check-attr", "text", "--", rel],
795
+ cwd=str(repo_root),
796
+ capture_output=True,
797
+ text=True,
798
+ encoding="utf-8",
799
+ errors="replace",
800
+ check=False,
801
+ )
802
+ attr_out = attr.stdout.strip() if attr.returncode == 0 else ""
803
+ byte_hash = "sha256:" + hashlib.sha256(content).hexdigest()
804
+ if attr_out.endswith(": unset") or b"\x00" in content:
805
+ return byte_hash
806
+ try:
807
+ content.decode("utf-8")
808
+ except UnicodeDecodeError:
809
+ return byte_hash
810
+ proc = subprocess.run(
811
+ ["git", "hash-object", "--path", rel, "--stdin"],
812
+ input=content,
813
+ capture_output=True,
814
+ cwd=str(repo_root),
815
+ check=False,
816
+ )
817
+ if proc.returncode != 0:
818
+ return byte_hash
819
+ return "gitblob:" + proc.stdout.decode("ascii").strip()
820
+
821
+
822
+ def compute_diff_hash(
823
+ repo_root: Path,
824
+ base: str | None = None,
825
+ change_dir: Path | str | None = None,
826
+ ) -> tuple[str, dict[str, Any]]:
827
+ """Byte-level, commit-invariant diff hash (cluster 2).
828
+
829
+ Content-based change set: every file whose working-tree content differs
830
+ from base, plus untracked files. Each entry is length-framed
831
+ (path-len | path | exists-flag | content-len | content) so the payload is
832
+ independent of shell, console encoding, BOM and system newlines. Stable
833
+ across a checkpoint commit because git does not mutate the working tree.
834
+ """
835
+ repo_root = Path(repo_root).resolve()
836
+ base, paths = _changed_paths(repo_root, base)
837
+ tracked_test_contents, manifest_path = _tracked_test_contents(repo_root, change_dir)
838
+ paths = sorted(set(paths).union(tracked_test_contents))
839
+ digest = hashlib.sha256()
840
+ digest.update(DIFF_HASH_VERSION.encode("utf-8"))
841
+ digest.update(b"\x00")
842
+ for rel in paths:
843
+ path_bytes = rel.encode("utf-8")
844
+ digest.update(len(path_bytes).to_bytes(4, "big"))
845
+ digest.update(path_bytes)
846
+ abs_path = repo_root / rel
847
+ if rel in tracked_test_contents:
848
+ content = tracked_test_contents[rel]
849
+ digest.update(b"\x01")
850
+ digest.update(len(content).to_bytes(8, "big"))
851
+ digest.update(content)
852
+ elif abs_path.is_file():
853
+ content = abs_path.read_bytes()
854
+ digest.update(b"\x01")
855
+ digest.update(len(content).to_bytes(8, "big"))
856
+ digest.update(content)
857
+ else:
858
+ # Deleted since base: record absence with no content.
859
+ digest.update(b"\x00")
860
+ digest.update((0).to_bytes(8, "big"))
861
+ for rel, verified_content in tracked_test_contents.items():
862
+ path = repo_root / rel
863
+ try:
864
+ current_content = path.read_bytes()
865
+ except OSError as exc:
866
+ raise ValueError(f"TEST_TRACKING_HASH_DRIFT: {rel}") from exc
867
+ if current_content != verified_content:
868
+ raise ValueError(f"TEST_TRACKING_HASH_DRIFT: {rel}")
869
+ try:
870
+ head = _git_bytes(["rev-parse", "HEAD"], repo_root).decode("utf-8", "replace").strip()
871
+ except RuntimeError:
872
+ head = ""
873
+ meta = {
874
+ "algorithmVersion": DIFF_HASH_VERSION,
875
+ "fileCount": len(paths),
876
+ "base": base,
877
+ "head": head or None,
878
+ "trackedTestFileCount": len(tracked_test_contents),
879
+ "testTrackingManifest": manifest_path,
880
+ }
881
+ return f"sha256:{digest.hexdigest()}", meta
882
+
883
+
884
+ def derive_coverage(verification: str, scope: str | None) -> str:
885
+ """Derive coverage lattice value from verification + scope (cluster 2)."""
886
+ s = str(scope).strip() if scope else ""
887
+ if verification == "unitTest":
888
+ return "module" if s in BROAD_SCOPES else "incremental"
889
+ if verification == "unitTestFull":
890
+ return "full" if s == "full" else "module"
891
+ if verification in ("install", "package"):
892
+ return "module-am"
893
+ return "module"
894
+
895
+
896
+ def expand_profile_input_files(
897
+ project: Path, profile_input: str
898
+ ) -> tuple[list[str], str | None]:
899
+ """Expand verificationInputs[profile_input] globs from build-profile.json.
900
+
901
+ Profile is loaded via ``harness_profile.load_profile`` (C7: common_root then
902
+ execution overlay) so linked worktrees without a local build-profile still
903
+ reuse the main checkout profile. Globs remain relative to the execution
904
+ ``project`` root (not common_root) so inputsHash tracks the tree under test.
905
+
906
+ Returns (files, error); error is None on success.
907
+ profile 缺失 / key 缺失 / glob 无匹配 / 结果为空 → 返回 ([], "<reason>"),
908
+ 调用方据此返回 insufficient-evidence,执行全量测试但不允许缓存复用。
909
+ """
910
+ profile = harness_profile.load_profile(Path(project))
911
+ if profile is None:
912
+ # load_profile swallows JSON errors; restore actionable unreadable diag
913
+ # when a profile file exists but cannot be parsed (review YELLOW-1).
914
+ project_root = Path(project).resolve()
915
+ common = harness_paths.common_root(project_root)
916
+ for root in (common, project_root):
917
+ candidate = root / ".harness" / "config" / "build-profile.json"
918
+ if not candidate.is_file():
919
+ continue
920
+ try:
921
+ json.loads(candidate.read_text(encoding="utf-8-sig"))
922
+ except (OSError, json.JSONDecodeError) as exc:
923
+ return [], f"build-profile.json unreadable: {exc}"
924
+ return [], "build-profile.json missing; run harness_preflight.py detect"
925
+ if not isinstance(profile, dict):
926
+ return [], "build-profile.json is not an object"
927
+ inputs = profile.get("verificationInputs")
928
+ if not isinstance(inputs, dict) or profile_input not in inputs:
929
+ return [], f"verificationInputs.{profile_input} missing in build-profile.json"
930
+ patterns = inputs[profile_input]
931
+ if not isinstance(patterns, list) or not patterns:
932
+ return [], f"verificationInputs.{profile_input} is empty or invalid"
933
+
934
+ base = Path(project).resolve()
935
+ seen: set[str] = set()
936
+ for pat in patterns:
937
+ if not isinstance(pat, str) or not pat.strip():
938
+ continue
939
+ for match in base.glob(pat):
940
+ if not match.is_file():
941
+ continue
942
+ resolved = match.resolve()
943
+ try:
944
+ resolved.relative_to(base)
945
+ except ValueError:
946
+ # 拒绝 project 外部路径,禁止 glob 逃逸。
947
+ continue
948
+ seen.add(resolved.as_posix())
949
+ if not seen:
950
+ return [], f"verificationInputs.{profile_input} matched no files"
951
+ return sorted(seen), None
952
+
953
+
954
+ def ensure_profile_input_target(
955
+ project: Path,
956
+ profile_input: str,
957
+ ) -> tuple[dict[str, Any] | None, str | None]:
958
+ """Refresh a missing/stale detected profile before recording evidence."""
959
+
960
+ project_root = Path(project).resolve()
961
+ profile = harness_profile.load_profile(project_root)
962
+ inputs = profile.get("verificationInputs") if isinstance(profile, dict) else None
963
+ graph = profile.get("verificationGraph") if isinstance(profile, dict) else None
964
+ targets = graph.get("targets") if isinstance(graph, dict) else None
965
+ if (
966
+ isinstance(inputs, dict)
967
+ and profile_input in inputs
968
+ and isinstance(targets, dict)
969
+ and isinstance(targets.get(profile_input), dict)
970
+ ):
971
+ return profile, None
972
+
973
+ # An unreadable profile needs an explicit repair; silently replacing it can
974
+ # discard user-owned overrides.
975
+ common = harness_paths.common_root(project_root)
976
+ for root in dict.fromkeys((common, project_root)):
977
+ candidate = root / ".harness" / "config" / "build-profile.json"
978
+ if not candidate.is_file():
979
+ continue
980
+ try:
981
+ json.loads(candidate.read_text(encoding="utf-8-sig"))
982
+ except (OSError, json.JSONDecodeError) as exc:
983
+ return None, f"build-profile.json unreadable: {exc}"
984
+
985
+ detected = harness_profile.detect(project_root)
986
+ if not detected.get("ok"):
987
+ return None, str(
988
+ detected.get("message")
989
+ or detected.get("code")
990
+ or "build profile detection failed"
991
+ )
992
+ refreshed = harness_profile.load_profile(project_root)
993
+ if not isinstance(refreshed, dict):
994
+ return None, "build-profile.json missing after detection"
995
+ return refreshed, None
996
+
997
+
998
+ def _state_dir(change_dir: Path) -> Path:
999
+ return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
1000
+
1001
+
1002
+ def ledger_candidates(change_dir: Path) -> list[Path]:
1003
+ state = _state_dir(change_dir)
1004
+ contract = Path(change_dir)
1005
+ candidates = [state / "evidence" / "verification-ledger.json"]
1006
+ if state != contract:
1007
+ candidates.append(contract / "evidence" / "verification-ledger.json")
1008
+ candidates.append(state / "verification-ledger.json")
1009
+ if state != contract:
1010
+ candidates.append(contract / "verification-ledger.json")
1011
+ return candidates
1012
+
1013
+
1014
+ def find_ledger_path(change_dir: Path) -> Path | None:
1015
+ for path in ledger_candidates(change_dir):
1016
+ if path.is_file():
1017
+ return path
1018
+ return None
1019
+
1020
+
1021
+ def preferred_write_path(change_dir: Path) -> Path:
1022
+ # New writes always go to evidence/ (protocol preferred path); split-v1
1023
+ # changes route to the dynamic state root.
1024
+ return _state_dir(change_dir) / "evidence" / "verification-ledger.json"
1025
+
1026
+
1027
+ def load_ledger(change_dir: Path) -> tuple[dict[str, Any] | None, Path | None]:
1028
+ path = find_ledger_path(change_dir)
1029
+ if path is None:
1030
+ return None, None
1031
+ text = path.read_text(encoding="utf-8-sig")
1032
+ if not text.strip():
1033
+ return {}, path
1034
+ data = json.loads(text)
1035
+ if data is None:
1036
+ return {}, path
1037
+ if not isinstance(data, dict):
1038
+ raise ValueError(f"ledger must be a JSON object: {path}")
1039
+ return data, path
1040
+
1041
+
1042
+ def write_ledger(path: Path, data: dict[str, Any]) -> None:
1043
+ """Atomic ledger write: temp -> fsync -> replace (ledger v3)."""
1044
+ path.parent.mkdir(parents=True, exist_ok=True)
1045
+ payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1046
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
1047
+ try:
1048
+ with open(tmp, "w", encoding="utf-8", newline="\n") as handle:
1049
+ handle.write(payload)
1050
+ handle.flush()
1051
+ os.fsync(handle.fileno())
1052
+ os.replace(tmp, path)
1053
+ except BaseException:
1054
+ tmp.unlink(missing_ok=True)
1055
+ raise
1056
+
1057
+
1058
+ LEDGER_SCHEMA_VERSION = 3
1059
+ LEDGER_IDENTITY_FIELDS = (
1060
+ "repositoryId",
1061
+ "changeName",
1062
+ "baseCommit",
1063
+ "currentHead",
1064
+ "diffHash",
1065
+ "ownershipHash",
1066
+ )
1067
+
1068
+
1069
+ def validate_ledger_identity(ledger: dict[str, Any]) -> list[str]:
1070
+ """Missing/invalid top-level identity fields (ledger v3)."""
1071
+ missing: list[str] = []
1072
+ if not isinstance(ledger, dict):
1073
+ return ["ledger"]
1074
+ if ledger.get("schemaVersion") != LEDGER_SCHEMA_VERSION:
1075
+ missing.append("schemaVersion")
1076
+ for field in LEDGER_IDENTITY_FIELDS:
1077
+ value = ledger.get(field)
1078
+ if not isinstance(value, str) or not value.strip():
1079
+ missing.append(field)
1080
+ return missing
1081
+
1082
+
1083
+ def record_integration_hashes(
1084
+ ledger_path: Path,
1085
+ *,
1086
+ change_dir: Path | None = None,
1087
+ repository_id: str,
1088
+ merge_final_hash: str,
1089
+ ci_expected_head: str,
1090
+ remote_head: str,
1091
+ ) -> dict[str, Any]:
1092
+ """Atomically attach post-push hashes using the change contract's ledger rules."""
1093
+ path = Path(ledger_path).resolve()
1094
+ if not path.is_file():
1095
+ return {"ok": False, "code": "LEDGER_MISSING", "ledgerPath": str(path)}
1096
+ try:
1097
+ ledger = json.loads(path.read_text(encoding="utf-8-sig"))
1098
+ except (OSError, json.JSONDecodeError) as exc:
1099
+ return {
1100
+ "ok": False, "code": "LEDGER_INVALID", "ledgerPath": str(path),
1101
+ "message": str(exc),
1102
+ }
1103
+ legacy_contract = False
1104
+ if change_dir is not None:
1105
+ resolved_change_dir = Path(change_dir).resolve()
1106
+ if ledger.get("changeName") != resolved_change_dir.name:
1107
+ return {
1108
+ "ok": False,
1109
+ "code": "LEDGER_CHANGE_MISMATCH",
1110
+ "ledgerPath": str(path),
1111
+ }
1112
+ legacy_contract = not _contract_is_v2(resolved_change_dir)
1113
+
1114
+ missing = validate_ledger_identity(ledger)
1115
+ if missing and not legacy_contract:
1116
+ return {
1117
+ "ok": False,
1118
+ "code": "LEDGER_IDENTITY_INVALID",
1119
+ "ledgerPath": str(path),
1120
+ "missing": missing,
1121
+ }
1122
+ if not missing and ledger.get("repositoryId") != repository_id:
1123
+ return {
1124
+ "ok": False,
1125
+ "code": "LEDGER_REPOSITORY_MISMATCH",
1126
+ "ledgerPath": str(path),
1127
+ }
1128
+ values = {
1129
+ "mergeFinalHash": merge_final_hash,
1130
+ "ciExpectedHead": ci_expected_head,
1131
+ "remoteHead": remote_head,
1132
+ }
1133
+ invalid = [
1134
+ field for field, value in values.items()
1135
+ if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{40}", value) is None
1136
+ ]
1137
+ if invalid:
1138
+ return {
1139
+ "ok": False,
1140
+ "code": "FINAL_HASH_INVALID",
1141
+ "ledgerPath": str(path),
1142
+ "invalid": invalid,
1143
+ }
1144
+ if len(set(values.values())) != 1:
1145
+ return {
1146
+ "ok": False,
1147
+ "code": "FINAL_HASH_MISMATCH",
1148
+ "ledgerPath": str(path),
1149
+ **values,
1150
+ }
1151
+ ledger.update(values)
1152
+ ledger["integrationFinalizedAt"] = now_iso()
1153
+ write_ledger(path, ledger)
1154
+ return {"ok": True, "code": "INTEGRATION_HASHES_RECORDED", "ledgerPath": str(path), **values}
1155
+
1156
+
1157
+ def _contract_is_v2(change_dir: Path) -> bool:
1158
+ try:
1159
+ contract = harness_paths.load_change_contract(change_dir)
1160
+ except (OSError, ValueError):
1161
+ return False
1162
+ if harness_paths.contract_layout_kind(contract) == "split-v1":
1163
+ return True
1164
+ version = contract.get("schemaVersion")
1165
+ return isinstance(version, int) and version >= 2
1166
+
1167
+
1168
+ def ownership_hash(contract: dict[str, Any]) -> str:
1169
+ ownership = contract.get("ownership") or {}
1170
+ canonical = json.dumps(ownership, ensure_ascii=False, sort_keys=True)
1171
+ return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
1172
+
1173
+
1174
+ class LedgerMigrationError(ValueError):
1175
+ """A legacy ledger could not be upgraded without inventing identity."""
1176
+
1177
+
1178
+ def ledger_evidence_identity(ledger: dict[str, Any]) -> str:
1179
+ """Stable identity of evidence-bearing fields, excluding schema metadata."""
1180
+ evidence = {
1181
+ key: ledger[key]
1182
+ for key in (
1183
+ "validations",
1184
+ "verificationTargets",
1185
+ "integration",
1186
+ "artifacts",
1187
+ )
1188
+ if key in ledger
1189
+ }
1190
+ canonical = json.dumps(
1191
+ evidence,
1192
+ ensure_ascii=False,
1193
+ sort_keys=True,
1194
+ separators=(",", ":"),
1195
+ ).encode("utf-8")
1196
+ return "sha256:" + hashlib.sha256(canonical).hexdigest()
1197
+
1198
+
1199
+ def _immutable_change_base(
1200
+ change_dir: Path,
1201
+ project_root: Path,
1202
+ ) -> str:
1203
+ roots = [change_dir]
1204
+ try:
1205
+ state_root = harness_paths.resolve_state_dir_for_contract(
1206
+ change_dir,
1207
+ project_root,
1208
+ )
1209
+ if state_root.resolve() != change_dir.resolve():
1210
+ roots.insert(0, state_root)
1211
+ except (OSError, ValueError):
1212
+ pass
1213
+ for root in roots:
1214
+ for relative in (
1215
+ Path("meta") / "state-snapshot.json",
1216
+ Path("state-snapshot.json"),
1217
+ ):
1218
+ path = root / relative
1219
+ if not path.is_file():
1220
+ continue
1221
+ try:
1222
+ payload = json.loads(path.read_text(encoding="utf-8-sig"))
1223
+ except (OSError, json.JSONDecodeError, TypeError):
1224
+ continue
1225
+ if not isinstance(payload, dict):
1226
+ continue
1227
+ value = str(payload.get("changeBase") or "").strip()
1228
+ if value:
1229
+ return value
1230
+ return ""
1231
+
1232
+
1233
+ def migrate_ledger_for_write(
1234
+ change_dir: Path,
1235
+ ledger: dict[str, Any],
1236
+ *,
1237
+ project_root: Path | None,
1238
+ base_commit: str | None,
1239
+ diff_hash: str | None,
1240
+ record_migration: bool,
1241
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
1242
+ """Upgrade one supported legacy window to v3 before the caller mutates evidence."""
1243
+ raw_schema = ledger.get("schemaVersion")
1244
+ source_schema = raw_schema if isinstance(raw_schema, int) else 1
1245
+ if source_schema not in {1, 2, LEDGER_SCHEMA_VERSION}:
1246
+ raise LedgerMigrationError(
1247
+ f"unsupported ledger schemaVersion={raw_schema!r}; "
1248
+ f"supported source versions are 1, 2, and {LEDGER_SCHEMA_VERSION}"
1249
+ )
1250
+ repo_probe = project_root or change_dir
1251
+ repo_root_raw = _git_text(repo_probe, "rev-parse", "--show-toplevel")
1252
+ current_head = _git_text(repo_probe, "rev-parse", "--verify", "HEAD")
1253
+ if not repo_root_raw or not current_head:
1254
+ raise LedgerMigrationError(
1255
+ "repository root/current HEAD could not be established"
1256
+ )
1257
+ repo_root = Path(repo_root_raw).resolve()
1258
+ try:
1259
+ contract = harness_paths.load_change_contract(change_dir)
1260
+ except (OSError, ValueError) as exc:
1261
+ raise LedgerMigrationError(
1262
+ f"change ownership contract could not be loaded: {exc}"
1263
+ ) from exc
1264
+
1265
+ resolved_base = base_commit
1266
+ if not _nonempty_str(resolved_base):
1267
+ resolved_base = ledger.get("baseCommit")
1268
+ if not _nonempty_str(resolved_base):
1269
+ resolved_base = _immutable_change_base(change_dir, repo_root)
1270
+ if not _nonempty_str(resolved_base):
1271
+ if record_migration:
1272
+ raise LedgerMigrationError(
1273
+ "immutable change base is missing; recapture the Plan boundary "
1274
+ "or pass an explicit --base-commit instead of using write-time HEAD"
1275
+ )
1276
+ # A genuinely new ledger establishes its identity at the first write.
1277
+ # Existing ledgers are handled above and may never take this fallback.
1278
+ resolved_base = current_head
1279
+ resolved_diff = diff_hash
1280
+ if not _nonempty_str(resolved_diff):
1281
+ try:
1282
+ resolved_diff = compute_ownership_diff(
1283
+ repo_root,
1284
+ base=str(resolved_base).strip(),
1285
+ change_dir=change_dir,
1286
+ )["diffHash"]
1287
+ except (OSError, ValueError, RuntimeError) as exc:
1288
+ raise LedgerMigrationError(
1289
+ f"ownership diff identity could not be computed: {exc}"
1290
+ ) from exc
1291
+
1292
+ before_identity = ledger_evidence_identity(ledger)
1293
+ migrated = dict(ledger)
1294
+ migrated["schemaVersion"] = LEDGER_SCHEMA_VERSION
1295
+ try:
1296
+ migrated["repositoryId"] = harness_paths.repository_identity(repo_root)
1297
+ migrated["ownershipHash"] = ownership_hash(contract)
1298
+ except (OSError, TypeError, ValueError, RuntimeError) as exc:
1299
+ raise LedgerMigrationError(
1300
+ f"repository/ownership identity could not be established: {exc}"
1301
+ ) from exc
1302
+ migrated["changeName"] = change_dir.name
1303
+ migrated["baseCommit"] = str(resolved_base).strip()
1304
+ migrated["currentHead"] = str(current_head).strip()
1305
+ migrated["diffHash"] = str(resolved_diff).strip()
1306
+ missing = validate_ledger_identity(migrated)
1307
+ if missing:
1308
+ raise LedgerMigrationError(
1309
+ "migrated ledger identity is incomplete: " + ", ".join(missing)
1310
+ )
1311
+
1312
+ receipt: dict[str, Any] | None = None
1313
+ if source_schema != LEDGER_SCHEMA_VERSION and record_migration:
1314
+ after_identity = ledger_evidence_identity(migrated)
1315
+ if after_identity != before_identity:
1316
+ raise LedgerMigrationError(
1317
+ "migration changed pre-existing evidence identity"
1318
+ )
1319
+ receipt = {
1320
+ "schemaVersion": 1,
1321
+ "action": "ledger-schema-migration",
1322
+ "originalSchemaVersion": source_schema,
1323
+ "targetSchemaVersion": LEDGER_SCHEMA_VERSION,
1324
+ "migratedAt": now_iso(),
1325
+ "evidenceIdentityBefore": before_identity,
1326
+ "evidenceIdentityAfter": after_identity,
1327
+ "repositoryId": migrated["repositoryId"],
1328
+ "baseCommit": migrated["baseCommit"],
1329
+ "currentHead": migrated["currentHead"],
1330
+ "diffHash": migrated["diffHash"],
1331
+ }
1332
+ receipt_identity = json.dumps(
1333
+ receipt,
1334
+ ensure_ascii=False,
1335
+ sort_keys=True,
1336
+ separators=(",", ":"),
1337
+ ).encode("utf-8")
1338
+ receipt["receiptId"] = (
1339
+ "sha256:" + hashlib.sha256(receipt_identity).hexdigest()
1340
+ )
1341
+ history = migrated.get("migrationHistory")
1342
+ if history is None:
1343
+ history = []
1344
+ if not isinstance(history, list):
1345
+ raise LedgerMigrationError("migrationHistory must be an array")
1346
+ migrated["migrationHistory"] = [*history, receipt]
1347
+ return migrated, receipt
1348
+
1349
+
1350
+ def _deterministic_rerecord_command(
1351
+ args: argparse.Namespace,
1352
+ *,
1353
+ files: list[str],
1354
+ project_root: Path | None,
1355
+ ) -> list[str]:
1356
+ command = [
1357
+ sys.executable,
1358
+ str(Path(__file__).resolve()),
1359
+ "--json",
1360
+ "record",
1361
+ "--change-dir",
1362
+ str(resolve_path(args.change_dir)),
1363
+ "--verification",
1364
+ str(args.verification),
1365
+ "--status",
1366
+ str(args.status),
1367
+ "--command",
1368
+ str(args.command),
1369
+ "--exit-code",
1370
+ str(args.exit_code),
1371
+ "--duration-ms",
1372
+ str(args.duration_ms),
1373
+ "--evidence",
1374
+ str(args.evidence),
1375
+ "--files",
1376
+ ",".join(sorted(str(Path(item).resolve()) for item in files)),
1377
+ ]
1378
+ if project_root is not None:
1379
+ command.extend(["--project", str(project_root)])
1380
+ for flag, attr in (
1381
+ ("--scope", "scope"),
1382
+ ("--coverage", "coverage"),
1383
+ ("--base-commit", "base_commit"),
1384
+ ("--diff-hash", "diff_hash"),
1385
+ ):
1386
+ value = getattr(args, attr, None)
1387
+ if _nonempty_str(value):
1388
+ command.extend([flag, str(value).strip()])
1389
+ return command
1390
+
1391
+
1392
+ _METRICS_SCHEMAS: dict[str, dict[str, tuple[str, ...]]] = {
1393
+ "unitTest": {"required": ("total", "passed", "failed"), "optional": ("errors", "skipped")},
1394
+ "unitTestFull": {"required": ("total", "passed", "failed"), "optional": ("errors", "skipped")},
1395
+ "apiTest": {"required": ("total", "passed", "failed"), "optional": ("blocked",)},
1396
+ "browserTest": {"required": ("total", "passed", "failed"), "optional": ("skipped", "retries")},
1397
+ "apiContract": {"required": ("scenariosTotal", "passed", "failed"), "optional": ("blocked",)},
1398
+ "browserE2E": {"required": ("total", "passed", "failed"), "optional": ("skipped", "retries")},
1399
+ }
1400
+
1401
+
1402
+ def validate_metrics(verification: str, metrics: Any) -> list[str]:
1403
+ """Typed metrics schema check; unknown verification types pass through."""
1404
+ problems: list[str] = []
1405
+ if not isinstance(metrics, dict):
1406
+ return ["metrics must be an object"]
1407
+ if verification == "dbCompatibility":
1408
+ applicability = metrics.get("applicability")
1409
+ if applicability not in ("APPLICABLE", "NOT_APPLICABLE"):
1410
+ return ["dbCompatibility.applicability must be APPLICABLE|NOT_APPLICABLE"]
1411
+ if applicability == "NOT_APPLICABLE":
1412
+ reason = metrics.get("reason")
1413
+ if not isinstance(reason, str) or not reason.strip():
1414
+ problems.append("dbCompatibility.reason required for NOT_APPLICABLE")
1415
+ unknown = sorted(set(metrics) - {"applicability", "reason"})
1416
+ problems.extend(
1417
+ f"dbCompatibility.{field} is not valid for NOT_APPLICABLE"
1418
+ for field in unknown
1419
+ )
1420
+ else:
1421
+ allowed = {
1422
+ "applicability", "status", "total", "passed", "failed", "evidenceHash"
1423
+ }
1424
+ status = metrics.get("status")
1425
+ if status not in {"OK", "FAIL"}:
1426
+ problems.append("dbCompatibility.status must be OK|FAIL for APPLICABLE")
1427
+ for field in ("total", "passed", "failed"):
1428
+ value = metrics.get(field)
1429
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
1430
+ problems.append(f"dbCompatibility.{field} must be a non-negative int")
1431
+ total = metrics.get("total")
1432
+ passed = metrics.get("passed")
1433
+ failed = metrics.get("failed")
1434
+ if all(isinstance(value, int) and not isinstance(value, bool)
1435
+ for value in (total, passed, failed)) and passed + failed != total:
1436
+ problems.append("dbCompatibility counts must satisfy passed + failed == total")
1437
+ evidence_hash = metrics.get("evidenceHash")
1438
+ if not isinstance(evidence_hash, str) or not re.fullmatch(
1439
+ r"sha256:[0-9a-f]{64}", evidence_hash
1440
+ ):
1441
+ problems.append("dbCompatibility.evidenceHash must be sha256:<64 lowercase hex>")
1442
+ problems.extend(
1443
+ f"dbCompatibility.{field} is not a supported field"
1444
+ for field in sorted(set(metrics) - allowed)
1445
+ )
1446
+ return problems
1447
+ schema = _METRICS_SCHEMAS.get(verification)
1448
+ if schema is None:
1449
+ return problems
1450
+ allowed = set(schema["required"]) | set(schema["optional"])
1451
+ for field in schema["required"]:
1452
+ value = metrics.get(field)
1453
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
1454
+ problems.append(f"metrics.{field} must be a non-negative int")
1455
+ for field, value in metrics.items():
1456
+ if field not in allowed:
1457
+ problems.append(f"metrics.{field} is not a {verification} field")
1458
+ elif not isinstance(value, int) or isinstance(value, bool) or value < 0:
1459
+ problems.append(f"metrics.{field} must be a non-negative int")
1460
+ return problems
1461
+
1462
+
1463
+ def build_applicability_entry(value: str, reason: str | None = None) -> dict[str, Any]:
1464
+ if value not in ("APPLICABLE", "NOT_APPLICABLE"):
1465
+ raise ValueError("applicability must be APPLICABLE|NOT_APPLICABLE")
1466
+ if value == "NOT_APPLICABLE" and not (isinstance(reason, str) and reason.strip()):
1467
+ raise ValueError("NOT_APPLICABLE requires a scope reason")
1468
+ entry: dict[str, Any] = {"applicability": value}
1469
+ if reason and reason.strip():
1470
+ entry["reason"] = reason.strip()
1471
+ return entry
1472
+
1473
+
1474
+ def applicability_counts_as_success(entry: dict[str, Any]) -> bool:
1475
+ """Applicability never contributes to success counters (RET-24)."""
1476
+ return False
1477
+
1478
+
1479
+ def applicability_counts_as_failure(entry: dict[str, Any]) -> bool:
1480
+ """NOT_APPLICABLE is not a failure; status decides for APPLICABLE."""
1481
+ return False
1482
+
1483
+
1484
+ _DYNAMIC_OWN_DIRS = ("events.ndjson", "logs", "evidence", "reports", "runtime", "backups")
1485
+
1486
+
1487
+ def _matches_ownership_path(rel: str, declared: Any) -> bool:
1488
+ scope = str(declared).replace("\\", "/").strip("/")
1489
+ if scope.endswith("/**"):
1490
+ scope = scope[:-3].rstrip("/")
1491
+ if not scope:
1492
+ return False
1493
+ normalized = rel.replace("\\", "/").strip("/")
1494
+ return normalized == scope or normalized.startswith(scope + "/")
1495
+
1496
+
1497
+ def _classify_ownership_path(
1498
+ rel: str, change_name: str, ownership: dict[str, Any]
1499
+ ) -> str:
1500
+ """owned | staticEvidence | excludedRuntime | foreign."""
1501
+ normalized = rel.replace("\\", "/")
1502
+ if normalized.startswith(".harness/state/changes/"):
1503
+ owner = normalized.split("/")[3] if len(normalized.split("/")) > 3 else ""
1504
+ return "excludedRuntime" if owner == change_name else "foreign"
1505
+ if normalized.startswith(".harness/state/"):
1506
+ return "excludedRuntime"
1507
+ if normalized.startswith(".harness/changes/"):
1508
+ parts = normalized.split("/")
1509
+ owner = parts[2] if len(parts) > 2 else ""
1510
+ if owner and owner != change_name:
1511
+ return "foreign"
1512
+ remainder = "/".join(parts[3:]) if len(parts) > 3 else ""
1513
+ head = remainder.split("/")[0] if remainder else ""
1514
+ if remainder in _DYNAMIC_OWN_DIRS or head in _DYNAMIC_OWN_DIRS:
1515
+ return "excludedRuntime"
1516
+ for excluded in ownership.get("excludedPaths") or []:
1517
+ if _matches_ownership_path(normalized, excluded):
1518
+ return "excludedRuntime"
1519
+ for static_path in ownership.get("staticEvidencePaths") or []:
1520
+ if _matches_ownership_path(normalized, static_path):
1521
+ return "staticEvidence"
1522
+ for product_path in ownership.get("productPaths") or []:
1523
+ if _matches_ownership_path(normalized, product_path):
1524
+ return "owned"
1525
+ return "foreign"
1526
+
1527
+
1528
+ def compute_ownership_diff(
1529
+ repo_root: Path, *, base: str, change_dir: Path, head: str | None = None
1530
+ ) -> dict[str, Any]:
1531
+ """diffHash over the change's ownership scope only (RET-18).
1532
+
1533
+ Excludes .harness/state/** and dynamic evidence; reports foreign change
1534
+ paths separately instead of folding them into the hash.
1535
+ """
1536
+ repo_root = Path(repo_root).resolve()
1537
+ change_dir = Path(change_dir).resolve()
1538
+ try:
1539
+ contract = harness_paths.load_change_contract(change_dir)
1540
+ except (OSError, ValueError):
1541
+ contract = {}
1542
+ ownership = contract.get("ownership") or {}
1543
+ diff_args = ["diff", "--name-only", base]
1544
+ if head:
1545
+ diff_args.append(head)
1546
+ raw = _git_text(repo_root, *diff_args) or ""
1547
+ changed_paths = {line.strip() for line in raw.splitlines() if line.strip()}
1548
+ if head is None:
1549
+ untracked = _git_text(
1550
+ repo_root, "ls-files", "--others", "--exclude-standard"
1551
+ ) or ""
1552
+ changed_paths.update(
1553
+ line.strip() for line in untracked.splitlines() if line.strip()
1554
+ )
1555
+ owned: list[str] = []
1556
+ static_evidence: list[str] = []
1557
+ foreign: list[str] = []
1558
+ excluded_runtime = 0
1559
+ for rel in sorted(changed_paths):
1560
+ verdict = _classify_ownership_path(rel, change_dir.name, ownership)
1561
+ if verdict == "foreign":
1562
+ foreign.append(rel)
1563
+ elif verdict == "staticEvidence":
1564
+ static_evidence.append(rel)
1565
+ elif verdict == "excludedRuntime":
1566
+ excluded_runtime += 1
1567
+ else:
1568
+ owned.append(rel)
1569
+ owned.sort()
1570
+ static_evidence.sort()
1571
+ foreign.sort()
1572
+
1573
+ hasher = hashlib.sha256()
1574
+ for rel in owned:
1575
+ hasher.update(rel.encode("utf-8"))
1576
+ hasher.update(b"\x00")
1577
+ if head:
1578
+ try:
1579
+ content = _git_bytes(["show", f"{head}:{rel}"], repo_root)
1580
+ except RuntimeError:
1581
+ content = None
1582
+ hasher.update(
1583
+ hashlib.sha256(content).hexdigest().encode("ascii")
1584
+ if content is not None
1585
+ else b"<deleted>"
1586
+ )
1587
+ else:
1588
+ content_path = (repo_root / rel).resolve()
1589
+ if content_path.is_file() and _inside(content_path, repo_root):
1590
+ hasher.update(hashlib.sha256(content_path.read_bytes()).hexdigest().encode("ascii"))
1591
+ else:
1592
+ hasher.update(b"<deleted>")
1593
+ hasher.update(b"\x00")
1594
+ return {
1595
+ "diffHash": "sha256:" + hasher.hexdigest(),
1596
+ "files": owned,
1597
+ "staticEvidenceFiles": static_evidence,
1598
+ "foreignPaths": foreign,
1599
+ "excludedRuntimeCount": excluded_runtime,
1600
+ "ownedFileCount": len(owned),
1601
+ "ownershipHash": ownership_hash(contract),
1602
+ }
1603
+
1604
+
1605
+ def normalize_status(raw: str) -> str:
1606
+ if raw not in STATUS_MAP:
1607
+ raise ValueError(
1608
+ f"unsupported status: {raw}; expected one of ok|fail|not_run (case variants OK/FAIL/NOT_RUN)"
1609
+ )
1610
+ return STATUS_MAP[raw]
1611
+
1612
+
1613
+ def evidence_summary(entry: dict[str, Any]) -> dict[str, Any]:
1614
+ return {
1615
+ "status": entry.get("status"),
1616
+ "command": entry.get("command"),
1617
+ "evidence": entry.get("evidence"),
1618
+ "scope": entry.get("scope"),
1619
+ "inputsHash": entry.get("inputsHash"),
1620
+ "inputsFiles": entry.get("inputsFiles"),
1621
+ "durationMs": entry.get("durationMs"),
1622
+ "exitCode": entry.get("exitCode"),
1623
+ "finishedAt": entry.get("finishedAt"),
1624
+ }
1625
+
1626
+
1627
+ def _nonempty_str(value: Any) -> bool:
1628
+ return isinstance(value, str) and bool(value.strip())
1629
+
1630
+
1631
+ def _scenario_receipt_error(code: str, message: str) -> dict[str, Any]:
1632
+ return {"ok": False, "code": code, "error": message}
1633
+
1634
+
1635
+ def resolve_scenario_manifest(manifest: Any) -> dict[str, Any]:
1636
+ """把 v2 artifact 包装体解成 legacy 形状;legacy 输入原样返回。
1637
+
1638
+ 解包规则住在 harness_plan_finalize(它定义了 legacy manifest 的 schema),
1639
+ 门禁与本模块共用同一份,两边不会各推一套。
1640
+
1641
+ 此前这里只判包装体、一律 fail-closed;更早的时候连判都不判——cmd_record
1642
+ 的版本探测初值是 0,包装体探测不出 schemaVersion 就停在 0,
1643
+ `manifest_schema >= 2` 判假,--scenario-receipt-file 的强制要求被静默跳过。
1644
+
1645
+ 返回 ``{"ok": True, "manifest": ...}`` 或 ``{"ok": False, "code": ...}``。
1646
+ """
1647
+ unpacked = hpf.unpack_v2_scenario_manifest(manifest)
1648
+ if unpacked is None:
1649
+ return {"ok": True, "manifest": manifest}
1650
+ return unpacked
1651
+
1652
+
1653
+ def _resolve_receipt_path(
1654
+ raw: str, change_dir: Path
1655
+ ) -> tuple[Path | None, list[Path]]:
1656
+ """Resolve --scenario-receipt-file against CWD *and* the change dir.
1657
+
1658
+ Skills write receipts under ``<change-dir>/runtime/``, so a bare
1659
+ ``runtime/scenario-receipt-*.json`` is the natural thing to pass. Resolving
1660
+ only against the CWD made that fail with a bare ENOENT. Return the first
1661
+ existing candidate plus every path tried, so the error can name them.
1662
+ """
1663
+ candidate = Path(raw).expanduser()
1664
+ if candidate.is_absolute():
1665
+ resolved = candidate.resolve()
1666
+ return (resolved if resolved.is_file() else None), [resolved]
1667
+ candidates = [
1668
+ (Path.cwd() / candidate).resolve(),
1669
+ (change_dir / candidate).resolve(),
1670
+ ]
1671
+ # De-duplicate while preserving order (CWD == change_dir is common).
1672
+ unique: list[Path] = []
1673
+ for item in candidates:
1674
+ if item not in unique:
1675
+ unique.append(item)
1676
+ for item in unique:
1677
+ if item.is_file():
1678
+ return item, unique
1679
+ return None, unique
1680
+
1681
+
1682
+ def _canonical_string_list(
1683
+ receipt: dict[str, Any],
1684
+ field: str,
1685
+ ) -> tuple[list[str] | None, dict[str, Any] | None]:
1686
+ raw = receipt.get(field)
1687
+ if not isinstance(raw, list) or any(not _nonempty_str(item) for item in raw):
1688
+ return None, _scenario_receipt_error(
1689
+ "SCENARIO_RECEIPT_INVALID",
1690
+ f"scenario receipt field '{field}' must be an array of non-empty strings",
1691
+ )
1692
+ values = [str(item).strip() for item in raw]
1693
+ if len(values) != len(set(values)):
1694
+ return None, _scenario_receipt_error(
1695
+ "SCENARIO_RECEIPT_INVALID",
1696
+ f"scenario receipt field '{field}' contains duplicate test IDs",
1697
+ )
1698
+ return values, None
1699
+
1700
+
1701
+ def validate_scenario_execution_receipt(
1702
+ *,
1703
+ change_dir: Path,
1704
+ scenario_ids: list[str],
1705
+ receipt: dict[str, Any],
1706
+ ) -> dict[str, Any]:
1707
+ """Validate and minimize runner output before it can satisfy scenario evidence."""
1708
+ manifest_path = change_dir / "meta" / "scenario-manifest.json"
1709
+ try:
1710
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
1711
+ except FileNotFoundError:
1712
+ return _scenario_receipt_error(
1713
+ "SCENARIO_MANIFEST_MISSING",
1714
+ f"scenario manifest does not exist: {manifest_path}",
1715
+ )
1716
+ except (OSError, json.JSONDecodeError) as exc:
1717
+ return _scenario_receipt_error("SCENARIO_MANIFEST_INVALID", str(exc))
1718
+ resolved = resolve_scenario_manifest(manifest)
1719
+ if not resolved["ok"]:
1720
+ return _scenario_receipt_error(
1721
+ str(resolved["code"]), str(resolved.get("message") or "")
1722
+ )
1723
+ manifest = resolved["manifest"]
1724
+ if not isinstance(manifest, dict) or not isinstance(manifest.get("scenarios"), list):
1725
+ return _scenario_receipt_error(
1726
+ "SCENARIO_MANIFEST_INVALID",
1727
+ "scenario-manifest.json must contain a scenarios array",
1728
+ )
1729
+ if int(manifest.get("schemaVersion") or 0) < 2:
1730
+ return _scenario_receipt_error(
1731
+ "SCENARIO_RECEIPT_UNSUPPORTED",
1732
+ "structured scenario receipts require scenario-manifest schemaVersion 2",
1733
+ )
1734
+ if receipt.get("schemaVersion") != 1:
1735
+ return _scenario_receipt_error(
1736
+ "SCENARIO_RECEIPT_INVALID",
1737
+ "scenario receipt schemaVersion must be 1",
1738
+ )
1739
+ runner = receipt.get("runner")
1740
+ if not isinstance(runner, dict) or not _nonempty_str(runner.get("name")):
1741
+ return _scenario_receipt_error(
1742
+ "SCENARIO_RECEIPT_INVALID",
1743
+ "scenario receipt runner.name is required",
1744
+ )
1745
+ if "version" in runner and not _nonempty_str(runner.get("version")):
1746
+ return _scenario_receipt_error(
1747
+ "SCENARIO_RECEIPT_INVALID",
1748
+ "scenario receipt runner.version must be a non-empty string when present",
1749
+ )
1750
+ attempt = receipt.get("attempt")
1751
+ if not isinstance(attempt, int) or isinstance(attempt, bool) or attempt < 1:
1752
+ return _scenario_receipt_error(
1753
+ "SCENARIO_RECEIPT_INVALID",
1754
+ "scenario receipt attempt must be a positive integer",
1755
+ )
1756
+
1757
+ declared, error = _canonical_string_list(receipt, "declared")
1758
+ if error:
1759
+ return error
1760
+ selected, error = _canonical_string_list(receipt, "selected")
1761
+ if error:
1762
+ return error
1763
+ assert declared is not None and selected is not None
1764
+
1765
+ collected_raw = receipt.get("collected")
1766
+ executed_raw = receipt.get("executed")
1767
+ if not isinstance(collected_raw, list) or not isinstance(executed_raw, list):
1768
+ return _scenario_receipt_error(
1769
+ "SCENARIO_RECEIPT_INVALID",
1770
+ "scenario receipt collected and executed fields must be arrays",
1771
+ )
1772
+
1773
+ collected: list[dict[str, str]] = []
1774
+ collected_keys: set[tuple[str, str, str]] = set()
1775
+ for index, item in enumerate(collected_raw):
1776
+ if not isinstance(item, dict) or any(
1777
+ not _nonempty_str(item.get(field))
1778
+ for field in ("testId", "file", "title")
1779
+ ):
1780
+ return _scenario_receipt_error(
1781
+ "SCENARIO_RECEIPT_INVALID",
1782
+ f"scenario receipt collected[{index}] has an invalid test identity",
1783
+ )
1784
+ canonical = {
1785
+ "testId": str(item["testId"]).strip(),
1786
+ "file": str(item["file"]).strip(),
1787
+ "title": str(item["title"]).strip(),
1788
+ }
1789
+ key = (canonical["testId"], canonical["file"], canonical["title"])
1790
+ if key in collected_keys:
1791
+ return _scenario_receipt_error(
1792
+ "SCENARIO_RECEIPT_INVALID",
1793
+ f"scenario receipt collected[{index}] duplicates a test identity",
1794
+ )
1795
+ collected_keys.add(key)
1796
+ collected.append(canonical)
1797
+
1798
+ executed: list[dict[str, Any]] = []
1799
+ executed_keys: set[tuple[str, str, str, int]] = set()
1800
+ allowed_statuses = {"PASSED", "FAILED", "SKIPPED"}
1801
+ for index, item in enumerate(executed_raw):
1802
+ if not isinstance(item, dict) or any(
1803
+ not _nonempty_str(item.get(field))
1804
+ for field in ("testId", "file", "title", "status")
1805
+ ):
1806
+ return _scenario_receipt_error(
1807
+ "SCENARIO_RECEIPT_INVALID",
1808
+ f"scenario receipt executed[{index}] has an invalid test identity",
1809
+ )
1810
+ item_attempt = item.get("attempt")
1811
+ if (
1812
+ not isinstance(item_attempt, int)
1813
+ or isinstance(item_attempt, bool)
1814
+ or item_attempt != attempt
1815
+ ):
1816
+ return _scenario_receipt_error(
1817
+ "SCENARIO_RECEIPT_INVALID",
1818
+ f"scenario receipt executed[{index}].attempt must equal receipt attempt",
1819
+ )
1820
+ item_status = str(item["status"]).strip().upper()
1821
+ if item_status not in allowed_statuses:
1822
+ return _scenario_receipt_error(
1823
+ "SCENARIO_RECEIPT_INVALID",
1824
+ f"scenario receipt executed[{index}].status is unsupported",
1825
+ )
1826
+ canonical = {
1827
+ "testId": str(item["testId"]).strip(),
1828
+ "file": str(item["file"]).strip(),
1829
+ "title": str(item["title"]).strip(),
1830
+ "attempt": item_attempt,
1831
+ "status": item_status,
1832
+ }
1833
+ key = (
1834
+ canonical["testId"],
1835
+ canonical["file"],
1836
+ canonical["title"],
1837
+ item_attempt,
1838
+ )
1839
+ if key in executed_keys:
1840
+ return _scenario_receipt_error(
1841
+ "SCENARIO_RECEIPT_INVALID",
1842
+ f"scenario receipt executed[{index}] duplicates a test attempt",
1843
+ )
1844
+ executed_keys.add(key)
1845
+ executed.append(canonical)
1846
+
1847
+ manifest_scenarios = {
1848
+ str(item.get("id") or "").strip(): item
1849
+ for item in manifest["scenarios"]
1850
+ if isinstance(item, dict) and _nonempty_str(item.get("id"))
1851
+ }
1852
+ unknown = sorted(set(scenario_ids) - set(manifest_scenarios))
1853
+ if unknown:
1854
+ return _scenario_receipt_error(
1855
+ "SCENARIO_ID_UNKNOWN",
1856
+ "scenario IDs are not declared in scenario-manifest.json: "
1857
+ + ", ".join(unknown),
1858
+ )
1859
+
1860
+ coverage = {
1861
+ key: []
1862
+ for key in (
1863
+ "declared",
1864
+ "selected",
1865
+ "collected",
1866
+ "executed",
1867
+ "passed",
1868
+ "skipped",
1869
+ "failed",
1870
+ "unexecuted",
1871
+ )
1872
+ }
1873
+ for scenario_id in scenario_ids:
1874
+ scenario = manifest_scenarios[scenario_id]
1875
+ if scenario.get("requiredEvidenceKind") != "ledger":
1876
+ continue
1877
+ if any(
1878
+ not _nonempty_str(scenario.get(field))
1879
+ for field in ("executableTestId", "testFile", "testTitle")
1880
+ ):
1881
+ return _scenario_receipt_error(
1882
+ "SCENARIO_MANIFEST_INVALID",
1883
+ f"scenario {scenario_id} is missing its executable test identity",
1884
+ )
1885
+ test_id = str(scenario["executableTestId"]).strip()
1886
+ identity = (
1887
+ test_id,
1888
+ str(scenario["testFile"]).strip(),
1889
+ str(scenario["testTitle"]).strip(),
1890
+ )
1891
+ if test_id in declared:
1892
+ coverage["declared"].append(scenario_id)
1893
+ if test_id in selected:
1894
+ coverage["selected"].append(scenario_id)
1895
+ if identity in collected_keys:
1896
+ coverage["collected"].append(scenario_id)
1897
+ selection_closed = (
1898
+ test_id in declared
1899
+ and test_id in selected
1900
+ and identity in collected_keys
1901
+ )
1902
+ matches = [
1903
+ item
1904
+ for item in executed
1905
+ if (
1906
+ item["testId"],
1907
+ item["file"],
1908
+ item["title"],
1909
+ )
1910
+ == identity
1911
+ ]
1912
+ if matches:
1913
+ coverage["executed"].append(scenario_id)
1914
+ terminal = matches[-1]["status"]
1915
+ if terminal == "PASSED" and selection_closed:
1916
+ coverage["passed"].append(scenario_id)
1917
+ elif terminal == "SKIPPED":
1918
+ coverage["skipped"].append(scenario_id)
1919
+ elif terminal == "FAILED":
1920
+ coverage["failed"].append(scenario_id)
1921
+ if not selection_closed:
1922
+ coverage["unexecuted"].append(scenario_id)
1923
+ else:
1924
+ coverage["unexecuted"].append(scenario_id)
1925
+
1926
+ canonical_runner = {"name": str(runner["name"]).strip()}
1927
+ if _nonempty_str(runner.get("version")):
1928
+ canonical_runner["version"] = str(runner["version"]).strip()
1929
+ return {
1930
+ "ok": True,
1931
+ "receipt": {
1932
+ "schemaVersion": 1,
1933
+ "runner": canonical_runner,
1934
+ "attempt": attempt,
1935
+ "declared": declared,
1936
+ "selected": selected,
1937
+ "collected": collected,
1938
+ "executed": executed,
1939
+ },
1940
+ "coverage": coverage,
1941
+ }
1942
+
1943
+
1944
+ def _scope_covers(ledger_scope: Any, requested_scope: str | None) -> bool:
1945
+ """unitTest: ledger scope must cover requested scope (broad scopes cover all)."""
1946
+ if not _nonempty_str(ledger_scope) and not isinstance(ledger_scope, list):
1947
+ return False
1948
+ if requested_scope is None or not str(requested_scope).strip():
1949
+ # No requested scope → only require ledger to have some scope recorded.
1950
+ return True
1951
+
1952
+ req = str(requested_scope).strip()
1953
+ if isinstance(ledger_scope, list):
1954
+ ledger_items = {str(x).strip() for x in ledger_scope if str(x).strip()}
1955
+ else:
1956
+ text = str(ledger_scope).strip()
1957
+ if text in BROAD_SCOPES:
1958
+ return True
1959
+ ledger_items = {p.strip() for p in text.split(",") if p.strip()}
1960
+
1961
+ if req in BROAD_SCOPES:
1962
+ # Requesting broad scope only reusable if ledger also broad (same or broader).
1963
+ return str(ledger_scope).strip() in BROAD_SCOPES if not isinstance(ledger_scope, list) else False
1964
+
1965
+ req_items = {p.strip() for p in req.split(",") if p.strip()}
1966
+ return req_items.issubset(ledger_items)
1967
+
1968
+
1969
+ def worktree_ready(ledger: dict[str, Any], change_dir: Path) -> bool:
1970
+ root = ledger.get("worktreeRoot")
1971
+ if root is not None and _nonempty_str(root):
1972
+ return True
1973
+ # Metadata aliases are accepted only when they resolve to a real linked
1974
+ # worktree of this repository. This keeps install reuse aligned with the
1975
+ # execution root used by context and gate, including `worktreePath`.
1976
+ return infer_execution_project_root(change_dir) is not None
1977
+
1978
+
1979
+ def decide_can_reuse(
1980
+ *,
1981
+ change_dir: Path,
1982
+ verification: str,
1983
+ files: list[str],
1984
+ requested_scope: str | None = None,
1985
+ requested_command: str | None = None,
1986
+ requested_toolchain_hash: str | None = None,
1987
+ requested_profile_hash: str | None = None,
1988
+ requested_environment_hash: str | None = None,
1989
+ requested_db_schema_hash: str | None = None,
1990
+ requested_product_tree_hash: str | None = None,
1991
+ requested_command_set_hash: str | None = None,
1992
+ requested_lock_hash: str | None = None,
1993
+ requested_target_identity: str | None = None,
1994
+ required_coverage: str | None = None,
1995
+ project_root: Path | str | None = None,
1996
+ ) -> dict[str, Any]:
1997
+ ledger, ledger_path = load_ledger(change_dir)
1998
+ if ledger is None:
1999
+ return {
2000
+ "ok": True,
2001
+ "reuse": False,
2002
+ "reason": "insufficient-evidence",
2003
+ "code": "LEDGER_MISSING",
2004
+ "executionNeed": "first-run",
2005
+ "verification": verification,
2006
+ "detail": "ledger missing",
2007
+ }
2008
+
2009
+ validations = ledger.get("validations")
2010
+ targets_raw = ledger.get("verificationTargets")
2011
+ targets = (
2012
+ list(targets_raw.values())
2013
+ if isinstance(targets_raw, dict)
2014
+ else targets_raw
2015
+ if isinstance(targets_raw, list)
2016
+ else []
2017
+ )
2018
+ target_entries = [
2019
+ target
2020
+ for target in targets
2021
+ if isinstance(target, dict) and target.get("verification") == verification
2022
+ ]
2023
+ target_entries.sort(key=lambda item: str(item.get("finishedAt") or ""))
2024
+ # A ledger can contain both a feature-tip and a later merge target. Select
2025
+ # the newest candidate matching the requested canonical key, rather than
2026
+ # letting an unrelated newest commit mask reusable evidence.
2027
+ requested_key_values = {
2028
+ "productTreeHash": requested_product_tree_hash,
2029
+ "commandSetHash": requested_command_set_hash,
2030
+ "toolchainHash": requested_toolchain_hash,
2031
+ "profileHash": requested_profile_hash,
2032
+ "environmentHash": requested_environment_hash,
2033
+ "lockHash": requested_lock_hash,
2034
+ "dbSchemaHash": requested_db_schema_hash,
2035
+ }
2036
+ matching_entries = [
2037
+ candidate
2038
+ for candidate in target_entries
2039
+ if all(
2040
+ not _nonempty_str(requested)
2041
+ or str(candidate.get(field) or "").strip().removeprefix("sha256:")
2042
+ == str(requested).strip().removeprefix("sha256:")
2043
+ for field, requested in requested_key_values.items()
2044
+ )
2045
+ and (
2046
+ not _nonempty_str(requested_command)
2047
+ or canonical_command(str(candidate.get("command") or ""))
2048
+ == canonical_command(str(requested_command))
2049
+ )
2050
+ ]
2051
+ entry = (
2052
+ matching_entries[-1]
2053
+ if matching_entries
2054
+ else target_entries[-1]
2055
+ if target_entries
2056
+ else None
2057
+ )
2058
+ if not isinstance(validations, dict) and entry is None:
2059
+ return {
2060
+ "ok": True,
2061
+ "reuse": False,
2062
+ "reason": "insufficient-evidence",
2063
+ "code": "VALIDATIONS_MISSING",
2064
+ "executionNeed": "first-run",
2065
+ "verification": verification,
2066
+ "detail": "validations missing",
2067
+ "ledger_path": str(ledger_path) if ledger_path else None,
2068
+ }
2069
+
2070
+ if entry is None and isinstance(validations, dict):
2071
+ entry = validations.get(verification)
2072
+ if not isinstance(entry, dict):
2073
+ return {
2074
+ "ok": True,
2075
+ "reuse": False,
2076
+ "reason": "insufficient-evidence",
2077
+ "code": "VALIDATION_MISSING",
2078
+ "executionNeed": "first-run",
2079
+ "verification": verification,
2080
+ "detail": f"validation '{verification}' missing",
2081
+ "ledger_path": str(ledger_path) if ledger_path else None,
2082
+ }
2083
+
2084
+ stored_hash = entry.get("inputsHash")
2085
+ stored_files = entry.get("inputsFiles")
2086
+ status = entry.get("status")
2087
+ evidence = entry.get("evidence")
2088
+ command = entry.get("command")
2089
+ scope = entry.get("scope")
2090
+ algorithm_version = entry.get("algorithmVersion")
2091
+ coverage = entry.get("coverage")
2092
+ stored_target_identity = entry.get("targetIdentity")
2093
+
2094
+ if entry.get("reusable") is False or isinstance(entry.get("invalidation"), dict):
2095
+ return {
2096
+ "ok": True,
2097
+ "reuse": False,
2098
+ "reason": "rerun",
2099
+ "code": "EVIDENCE_INVALIDATED",
2100
+ "executionNeed": "rerun",
2101
+ "verification": verification,
2102
+ "detail": "recorded target was invalidated by workflow fixback",
2103
+ "invalidation": entry.get("invalidation"),
2104
+ }
2105
+
2106
+ # v2 fields: a v1 entry (no algorithmVersion/coverage) is conservatively
2107
+ # invalidated once and must be re-recorded with v2 fields (COM-002). No
2108
+ # silent upgrade of stale evidence.
2109
+ v2_missing: list[str] = []
2110
+ if not _nonempty_str(algorithm_version):
2111
+ v2_missing.append("algorithmVersion")
2112
+ if not (_nonempty_str(coverage) and str(coverage).strip() in COVERAGE_RANK):
2113
+ v2_missing.append("coverage")
2114
+
2115
+ missing: list[str] = []
2116
+ if not _nonempty_str(stored_hash):
2117
+ missing.append("inputsHash")
2118
+ if not isinstance(stored_files, list):
2119
+ missing.append("inputsFiles")
2120
+ elif verification == "unitTestFull" and not stored_files:
2121
+ # 全量门禁的依赖闭包文件集必须非空,禁止空/staged-only 闭包冒充全量。
2122
+ missing.append("inputsFiles")
2123
+ if status != "OK":
2124
+ missing.append("status=OK")
2125
+ if not _nonempty_str(evidence):
2126
+ missing.append("evidence")
2127
+ if not _nonempty_str(command):
2128
+ missing.append("command")
2129
+ if verification == "unitTest" and not (
2130
+ _nonempty_str(scope) or isinstance(scope, list)
2131
+ ):
2132
+ missing.append("scope")
2133
+ if verification == "unitTestFull":
2134
+ # 独立 full-scope 检查:增量范围(如 FooTest)不能冒充全量门禁。
2135
+ # 不并入 _scope_covers(),避免依赖增量复用的隐含行为。
2136
+ if not isinstance(scope, str) or scope.strip() not in BROAD_SCOPES:
2137
+ missing.append("scope=module|full")
2138
+ if verification == "install" and not worktree_ready(ledger, change_dir):
2139
+ missing.append("worktree")
2140
+
2141
+ all_missing = v2_missing + missing
2142
+ if all_missing:
2143
+ return {
2144
+ "ok": True,
2145
+ "reuse": False,
2146
+ "reason": "insufficient-evidence",
2147
+ "code": "MISSING_V2_FIELDS" if v2_missing else "MISSING_FIELDS",
2148
+ "executionNeed": "evidence-incomplete",
2149
+ "verification": verification,
2150
+ "detail": "missing or invalid: " + ", ".join(all_missing),
2151
+ "ledger_path": str(ledger_path) if ledger_path else None,
2152
+ }
2153
+
2154
+ # Coverage lattice: recorded coverage rank must meet the verification's
2155
+ # required rank. Stops incremental evidence satisfying a module/full gate
2156
+ # (UT-015 / API-005).
2157
+ required = (
2158
+ COVERAGE_RANK.get(required_coverage, 1)
2159
+ if required_coverage is not None
2160
+ else REQUIRED_COVERAGE.get(verification, 1)
2161
+ )
2162
+ if COVERAGE_RANK.get(str(coverage).strip(), -1) < required:
2163
+ return {
2164
+ "ok": True,
2165
+ "reuse": False,
2166
+ "reason": "insufficient-evidence",
2167
+ "code": "COVERAGE_INSUFFICIENT",
2168
+ "verification": verification,
2169
+ "detail": f"coverage '{coverage}' below required rank {required} for {verification}",
2170
+ "ledger_path": str(ledger_path) if ledger_path else None,
2171
+ "stored_coverage": coverage,
2172
+ }
2173
+
2174
+ if _nonempty_str(requested_target_identity):
2175
+ if not _nonempty_str(stored_target_identity):
2176
+ return {
2177
+ "ok": True,
2178
+ "reuse": False,
2179
+ "reason": "insufficient-evidence",
2180
+ "code": "MISSING_TARGET_IDENTITY",
2181
+ "verification": verification,
2182
+ "detail": "recorded evidence predates the current verification target identity",
2183
+ "ledger_path": str(ledger_path) if ledger_path else None,
2184
+ "requested_target_identity": requested_target_identity,
2185
+ }
2186
+ if str(stored_target_identity).strip() != str(requested_target_identity).strip():
2187
+ return {
2188
+ "ok": True,
2189
+ "reuse": False,
2190
+ "reason": "rerun",
2191
+ "code": "TARGET_IDENTITY_CHANGED",
2192
+ "verification": verification,
2193
+ "detail": "verification target definition changed",
2194
+ "ledger_path": str(ledger_path) if ledger_path else None,
2195
+ "stored_target_identity": stored_target_identity,
2196
+ "requested_target_identity": requested_target_identity,
2197
+ }
2198
+
2199
+ if requested_command is not None and str(requested_command).strip():
2200
+ if canonical_command(str(command)) != canonical_command(str(requested_command)):
2201
+ return {
2202
+ "ok": True,
2203
+ "reuse": False,
2204
+ "reason": "rerun",
2205
+ "code": "COMMAND_CHANGED",
2206
+ "verification": verification,
2207
+ "detail": "command changed",
2208
+ "ledger_path": str(ledger_path) if ledger_path else None,
2209
+ "stored_command": command,
2210
+ "requested_command": requested_command,
2211
+ }
2212
+
2213
+ # The reusable identity is content and execution context, never commit SHA.
2214
+ # productCommit remains provenance only so a feature tip can serve its no-ff
2215
+ # merge when the merge has exactly the same product tree.
2216
+ # Only compared when both the stored entry and the request carry the field.
2217
+ for field, requested, code_name in (
2218
+ ("productTreeHash", requested_product_tree_hash, "PRODUCT_TREE_CHANGED"),
2219
+ ("commandSetHash", requested_command_set_hash, "COMMAND_SET_CHANGED"),
2220
+ ("toolchainHash", requested_toolchain_hash, "TOOLCHAIN_CHANGED"),
2221
+ ("profileHash", requested_profile_hash, "PROFILE_CHANGED"),
2222
+ ("environmentHash", requested_environment_hash, "ENVIRONMENT_CHANGED"),
2223
+ ("lockHash", requested_lock_hash, "LOCK_CHANGED"),
2224
+ ("dbSchemaHash", requested_db_schema_hash, "DB_SCHEMA_CHANGED"),
2225
+ ):
2226
+ stored = entry.get(field)
2227
+ if requested and str(requested).strip() and not _nonempty_str(stored):
2228
+ return {
2229
+ "ok": True,
2230
+ "reuse": False,
2231
+ "reason": "insufficient-evidence",
2232
+ "code": "MISSING_TARGET_IDENTITY",
2233
+ "verification": verification,
2234
+ "detail": f"{field} missing from recorded target",
2235
+ "field": field,
2236
+ }
2237
+ if requested and str(requested).strip() and str(stored).strip() != str(requested).strip():
2238
+ return {
2239
+ "ok": True,
2240
+ "reuse": False,
2241
+ "reason": "rerun",
2242
+ "code": code_name,
2243
+ "verification": verification,
2244
+ "detail": f"{field} changed",
2245
+ "ledger_path": str(ledger_path) if ledger_path else None,
2246
+ "field": field,
2247
+ "stored": stored,
2248
+ "requested": requested,
2249
+ }
2250
+
2251
+ if verification == "unitTest" and not _scope_covers(scope, requested_scope):
2252
+ return {
2253
+ "ok": True,
2254
+ "reuse": False,
2255
+ "reason": "insufficient-evidence",
2256
+ "code": "SCOPE_INSUFFICIENT",
2257
+ "verification": verification,
2258
+ "detail": "scope does not cover requested tests",
2259
+ "ledger_path": str(ledger_path) if ledger_path else None,
2260
+ "stored_scope": scope,
2261
+ "requested_scope": requested_scope,
2262
+ }
2263
+
2264
+ try:
2265
+ current_hash, current_files = compute_inputs_hash(
2266
+ files, project_root=project_root
2267
+ )
2268
+ except (FileNotFoundError, ValueError) as exc:
2269
+ return {
2270
+ "ok": True,
2271
+ "reuse": False,
2272
+ "reason": "insufficient-evidence",
2273
+ "code": "INPUT_FILE_MISSING",
2274
+ "verification": verification,
2275
+ "detail": str(exc),
2276
+ "ledger_path": str(ledger_path) if ledger_path else None,
2277
+ }
2278
+
2279
+ if current_hash != stored_hash:
2280
+ return {
2281
+ "ok": True,
2282
+ "reuse": False,
2283
+ "reason": "rerun",
2284
+ "code": "INPUTS_HASH_CHANGED",
2285
+ "verification": verification,
2286
+ "detail": "inputsHash changed",
2287
+ "ledger_path": str(ledger_path) if ledger_path else None,
2288
+ "stored_inputsHash": stored_hash,
2289
+ "current_inputsHash": current_hash,
2290
+ "inputsFiles": current_files,
2291
+ }
2292
+
2293
+ return {
2294
+ "ok": True,
2295
+ "reuse": True,
2296
+ "reason": "reuse",
2297
+ "code": "REUSED",
2298
+ "executionNeed": "reuse",
2299
+ "invalidationCode": None,
2300
+ "verification": verification,
2301
+ "ledger_path": str(ledger_path) if ledger_path else None,
2302
+ "inputsHash": stored_hash,
2303
+ "inputsFiles": stored_files,
2304
+ "evidence_summary": evidence_summary(entry),
2305
+ "reusedFrom": {
2306
+ "targetId": entry.get("id"),
2307
+ "sourceCommit": entry.get("productCommit") or entry.get("currentHead"),
2308
+ "sourceCandidate": entry.get("candidateId"),
2309
+ },
2310
+ "reuseReason": "canonical reuse key matched; commit SHA is provenance only",
2311
+ "reuseKey": canonical_reuse_key(entry),
2312
+ "marker": "REUSED",
2313
+ }
2314
+
2315
+
2316
+ _INVALIDATION_ALIASES = {
2317
+ "INPUTS_HASH_CHANGED": "INPUT_CHANGED",
2318
+ "ENVIRONMENT_CHANGED": "ENV_CHANGED",
2319
+ "MISSING_FIELDS": "MISSING_IDENTITY_FIELD",
2320
+ "MISSING_V2_FIELDS": "MISSING_IDENTITY_FIELD",
2321
+ "LEDGER_MISSING": "MISSING_IDENTITY_FIELD",
2322
+ "VALIDATIONS_MISSING": "MISSING_IDENTITY_FIELD",
2323
+ "VALIDATION_MISSING": "MISSING_IDENTITY_FIELD",
2324
+ }
2325
+
2326
+
2327
+ def _attach_invalidation_code(payload: dict[str, Any]) -> dict[str, Any]:
2328
+ """Wave-2 H-9: expose stable invalidationCode alongside legacy code."""
2329
+ if payload.get("reuse") is True:
2330
+ payload.setdefault("invalidationCode", None)
2331
+ return payload
2332
+ code = str(payload.get("code") or "").strip()
2333
+ if code:
2334
+ payload["invalidationCode"] = _INVALIDATION_ALIASES.get(code, code)
2335
+ return payload
2336
+
2337
+
2338
+ def verification_graph_path(change_dir: Path) -> Path:
2339
+ return Path(change_dir) / "evidence" / "verification-graph.json"
2340
+
2341
+
2342
+ def upsert_verification_graph_node(
2343
+ change_dir: Path,
2344
+ *,
2345
+ verification: str,
2346
+ status: str,
2347
+ inputs_hash: str,
2348
+ command: str | None = None,
2349
+ toolchain_hash: str | None = None,
2350
+ environment_hash: str | None = None,
2351
+ db_schema_hash: str | None = None,
2352
+ diff_hash: str | None = None,
2353
+ target_identity: str | None = None,
2354
+ ) -> dict[str, Any]:
2355
+ """Persist a verification graph node keyed by canonical identity (H-9)."""
2356
+ path = verification_graph_path(change_dir)
2357
+ path.parent.mkdir(parents=True, exist_ok=True)
2358
+ graph: dict[str, Any]
2359
+ if path.is_file():
2360
+ try:
2361
+ loaded = json.loads(path.read_text(encoding="utf-8-sig"))
2362
+ graph = loaded if isinstance(loaded, dict) else {"schemaVersion": 1, "nodes": []}
2363
+ except (OSError, json.JSONDecodeError):
2364
+ graph = {"schemaVersion": 1, "nodes": []}
2365
+ else:
2366
+ graph = {"schemaVersion": 1, "nodes": []}
2367
+ nodes = graph.get("nodes")
2368
+ if not isinstance(nodes, list):
2369
+ nodes = []
2370
+ identity = {
2371
+ "verification": verification,
2372
+ "inputsHash": inputs_hash,
2373
+ "command": command,
2374
+ "toolchainHash": toolchain_hash,
2375
+ "environmentHash": environment_hash,
2376
+ "dbSchemaHash": db_schema_hash,
2377
+ "diffHash": diff_hash,
2378
+ "targetIdentity": target_identity,
2379
+ }
2380
+ node = {
2381
+ "identity": identity,
2382
+ "status": status,
2383
+ "recordedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
2384
+ }
2385
+ replaced = False
2386
+ for index, existing in enumerate(nodes):
2387
+ if not isinstance(existing, dict):
2388
+ continue
2389
+ existing_id = existing.get("identity")
2390
+ if isinstance(existing_id, dict) and existing_id == identity:
2391
+ nodes[index] = node
2392
+ replaced = True
2393
+ break
2394
+ if not replaced:
2395
+ nodes.append(node)
2396
+ graph["schemaVersion"] = 1
2397
+ graph["nodes"] = nodes
2398
+ write_ledger(path, graph) # atomic json write helper already used for ledger
2399
+ return {"ok": True, "path": str(path), "nodeCount": len(nodes), "replaced": replaced}
2400
+
2401
+
2402
+ def cmd_hash(args: argparse.Namespace) -> int:
2403
+ as_json = bool(args.json)
2404
+ try:
2405
+ files, project_root = input_files_from_args(args)
2406
+ if not files:
2407
+ return emit_error("hash requires --files or --files-from", as_json=as_json)
2408
+ inputs_hash, inputs_files = compute_inputs_hash(
2409
+ files, project_root=project_root
2410
+ )
2411
+ except (OSError, FileNotFoundError) as exc:
2412
+ return emit_error(str(exc), as_json=as_json)
2413
+
2414
+ payload = {
2415
+ "ok": True,
2416
+ "action": "hash",
2417
+ "inputsHash": inputs_hash,
2418
+ "inputsFiles": inputs_files,
2419
+ "fileCount": len(inputs_files),
2420
+ "resolvedProjectRoot": str(project_root) if project_root else None,
2421
+ }
2422
+ emit_json(payload, as_json=as_json)
2423
+ return 0
2424
+
2425
+
2426
+ def cmd_can_reuse(args: argparse.Namespace) -> int:
2427
+ as_json = bool(args.json)
2428
+ verbose = bool(getattr(args, "verbose", False))
2429
+ verification = args.verification
2430
+ change_dir = resolve_path(args.change_dir)
2431
+ apply_inferred_project_root(args, change_dir)
2432
+ ownership_check = frozen_ownership_check(change_dir)
2433
+ if not ownership_check.get("ok"):
2434
+ return emit_error(
2435
+ str(ownership_check.get("message")),
2436
+ as_json=as_json,
2437
+ error_code=str(ownership_check.get("code")),
2438
+ extra={
2439
+ key: value
2440
+ for key, value in ownership_check.items()
2441
+ if key not in {"ok", "code", "message"}
2442
+ },
2443
+ )
2444
+ try:
2445
+ files, project_root = input_files_from_args(args)
2446
+ except ProjectRootRequiredError as exc:
2447
+ return emit_error(
2448
+ str(exc),
2449
+ as_json=as_json,
2450
+ error_code="PROJECT_ROOT_REQUIRED",
2451
+ )
2452
+ except (OSError, ValueError) as exc:
2453
+ return emit_error(f"can-reuse failed: {exc}", as_json=as_json)
2454
+ target = resolve_verification_target(verification, project_root)
2455
+ if target is None:
2456
+ return emit_error(
2457
+ "unsupported verification: "
2458
+ f"{verification}; declare it in build-profile.json "
2459
+ "verificationGraph.targets",
2460
+ as_json=as_json,
2461
+ )
2462
+ explicit_files = list(files)
2463
+ profile_input = getattr(args, "profile_input", None)
2464
+ project_raw = getattr(args, "project", None)
2465
+ if profile_input:
2466
+ if not project_raw:
2467
+ return emit_error("--profile-input requires --project", as_json=as_json)
2468
+ resolved_files, err = expand_profile_input_files(project_root, profile_input)
2469
+ if err:
2470
+ # profile 未正确配置:不允许缓存复用,返回 insufficient-evidence(exit 0)。
2471
+ payload = {
2472
+ "ok": True,
2473
+ "reuse": False,
2474
+ "reason": "insufficient-evidence",
2475
+ "verification": verification,
2476
+ "detail": err,
2477
+ }
2478
+ emit_compact_or_verbose(
2479
+ payload, as_json=as_json, verbose=verbose,
2480
+ compact_fn=_compact_can_reuse_payload,
2481
+ )
2482
+ return 0
2483
+ profile_files = resolve_input_files(resolved_files, project_root)
2484
+ if explicit_files:
2485
+ _explicit_hash, explicit_names = compute_inputs_hash(
2486
+ explicit_files, project_root=project_root
2487
+ )
2488
+ _profile_hash, profile_names = compute_inputs_hash(
2489
+ profile_files, project_root=project_root
2490
+ )
2491
+ if explicit_names != profile_names:
2492
+ return emit_error(
2493
+ "PROFILE_INPUT_FILES_CONFLICT: --files does not match the "
2494
+ "declared build-profile input set",
2495
+ as_json=as_json,
2496
+ error_code="PROFILE_INPUT_FILES_CONFLICT",
2497
+ )
2498
+ files = profile_files
2499
+ if not files:
2500
+ return emit_error(
2501
+ "can-reuse requires --files or a non-empty --profile-input file set",
2502
+ as_json=as_json,
2503
+ )
2504
+ try:
2505
+ payload = decide_can_reuse(
2506
+ change_dir=change_dir,
2507
+ verification=verification,
2508
+ files=files,
2509
+ requested_scope=getattr(args, "scope", None),
2510
+ requested_command=(
2511
+ canonical_command(str(getattr(args, "command", "")))
2512
+ if _nonempty_str(getattr(args, "command", None))
2513
+ else None
2514
+ ),
2515
+ requested_toolchain_hash=(
2516
+ getattr(args, "toolchain_hash", None)
2517
+ or default_toolchain_hash(project_root)
2518
+ ),
2519
+ requested_profile_hash=getattr(args, "profile_hash", None),
2520
+ requested_environment_hash=(
2521
+ getattr(args, "environment_hash", None)
2522
+ or default_environment_hash(project_root)
2523
+ ),
2524
+ requested_db_schema_hash=getattr(args, "db_schema_hash", None),
2525
+ requested_product_tree_hash=(
2526
+ getattr(args, "product_tree_hash", None) or product_tree_hash(project_root)
2527
+ ),
2528
+ requested_command_set_hash=(
2529
+ getattr(args, "command_set_hash", None)
2530
+ or (
2531
+ command_set_hash(str(getattr(args, "command", "")))
2532
+ if _nonempty_str(getattr(args, "command", None))
2533
+ else None
2534
+ )
2535
+ ),
2536
+ requested_lock_hash=(
2537
+ getattr(args, "lock_hash", None) or lock_hash(project_root)
2538
+ ),
2539
+ requested_target_identity=verification_target_identity(
2540
+ verification, target
2541
+ ),
2542
+ required_coverage=str(target["requiredCoverage"]),
2543
+ project_root=project_root,
2544
+ )
2545
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
2546
+ return emit_error(f"can-reuse failed: {exc}", as_json=as_json)
2547
+
2548
+ payload = _attach_invalidation_code(payload)
2549
+ payload["resolvedProjectRoot"] = (
2550
+ str(project_root) if project_root is not None else None
2551
+ )
2552
+ emit_compact_or_verbose(
2553
+ payload, as_json=as_json, verbose=verbose,
2554
+ compact_fn=_compact_can_reuse_payload,
2555
+ )
2556
+ return 0
2557
+
2558
+
2559
+ def cmd_record(args: argparse.Namespace) -> int:
2560
+ as_json = bool(args.json)
2561
+ verbose = bool(getattr(args, "verbose", False))
2562
+ change_dir = resolve_path(args.change_dir)
2563
+ apply_inferred_project_root(args, change_dir)
2564
+ ownership_check = frozen_ownership_check(change_dir)
2565
+ if not ownership_check.get("ok"):
2566
+ return emit_error(
2567
+ str(ownership_check.get("message")),
2568
+ as_json=as_json,
2569
+ error_code=str(ownership_check.get("code")),
2570
+ extra={
2571
+ key: value
2572
+ for key, value in ownership_check.items()
2573
+ if key not in {"ok", "code", "message"}
2574
+ },
2575
+ )
2576
+ verification = args.verification
2577
+ contract_v2 = _contract_is_v2(change_dir)
2578
+ migration_receipt: dict[str, Any] | None = None
2579
+ try:
2580
+ files, project_root = input_files_from_args(args)
2581
+ except (OSError, ValueError) as exc:
2582
+ return emit_error(f"record failed: {exc}", as_json=as_json)
2583
+ explicit_files = list(files)
2584
+ profile_input = getattr(args, "profile_input", None)
2585
+ project_raw = getattr(args, "project", None)
2586
+ if profile_input:
2587
+ if not project_raw:
2588
+ return emit_error("--profile-input requires --project", as_json=as_json)
2589
+ _profile, profile_error = ensure_profile_input_target(
2590
+ project_root, profile_input
2591
+ )
2592
+ if profile_error:
2593
+ return emit_error(
2594
+ f"record failed: {profile_error}",
2595
+ as_json=as_json,
2596
+ error_code="BUILD_PROFILE_REFRESH_FAILED",
2597
+ )
2598
+ target = resolve_verification_target(verification, project_root)
2599
+ if target is None:
2600
+ return emit_error(
2601
+ "unsupported verification: "
2602
+ f"{verification}; declare it in build-profile.json "
2603
+ "verificationGraph.targets",
2604
+ as_json=as_json,
2605
+ )
2606
+ if profile_input:
2607
+ resolved_files, err = expand_profile_input_files(project_root, profile_input)
2608
+ if err:
2609
+ return emit_error(f"record failed: {err}", as_json=as_json)
2610
+ profile_files = resolve_input_files(resolved_files, project_root)
2611
+ if explicit_files:
2612
+ _explicit_hash, explicit_names = compute_inputs_hash(
2613
+ explicit_files, project_root=project_root
2614
+ )
2615
+ _profile_hash, profile_names = compute_inputs_hash(
2616
+ profile_files, project_root=project_root
2617
+ )
2618
+ if explicit_names != profile_names:
2619
+ return emit_error(
2620
+ "PROFILE_INPUT_FILES_CONFLICT: --files does not match the "
2621
+ "declared build-profile input set",
2622
+ as_json=as_json,
2623
+ error_code="PROFILE_INPUT_FILES_CONFLICT",
2624
+ )
2625
+ files = profile_files
2626
+ # NOT_APPLICABLE 的 validation 没有输入文件可言。门禁 close 又要求
2627
+ # requiredValidations 每项都有 entry——两条规则叠加会逼调用方拿无关文件凑数,
2628
+ # ledger 从此声称该 validation 的输入是那些文件,是假证据。
2629
+ not_applicable = str(getattr(args, "applicability", "") or "") == "NOT_APPLICABLE"
2630
+ if not files and not not_applicable:
2631
+ return emit_error(
2632
+ "record requires --files or a non-empty --profile-input file set; "
2633
+ "unless --applicability NOT_APPLICABLE (then --applicability-reason is required)",
2634
+ as_json=as_json,
2635
+ )
2636
+ if not_applicable and not _nonempty_str(getattr(args, "applicability_reason", None)):
2637
+ return emit_error(
2638
+ "NOT_APPLICABLE requires --applicability-reason",
2639
+ as_json=as_json,
2640
+ )
2641
+
2642
+ try:
2643
+ status = normalize_status(args.status)
2644
+ inputs_hash, inputs_files = compute_inputs_hash(
2645
+ files, project_root=project_root
2646
+ )
2647
+ ledger, existing_path = load_ledger(change_dir)
2648
+ if ledger is None:
2649
+ ledger = {
2650
+ "changeName": change_dir.name,
2651
+ "stateDir": str(change_dir),
2652
+ "validations": {},
2653
+ }
2654
+ elif not isinstance(ledger.get("validations"), dict):
2655
+ ledger["validations"] = {}
2656
+ explicit_identity = any(
2657
+ _nonempty_str(getattr(args, field, None))
2658
+ for field in ("base_commit", "diff_hash")
2659
+ )
2660
+ existing_schema = ledger.get("schemaVersion")
2661
+ identity_write = (
2662
+ contract_v2
2663
+ or explicit_identity
2664
+ or existing_schema in {2, LEDGER_SCHEMA_VERSION}
2665
+ )
2666
+ if identity_write:
2667
+ try:
2668
+ ledger, migration_receipt = migrate_ledger_for_write(
2669
+ change_dir,
2670
+ ledger,
2671
+ project_root=project_root,
2672
+ base_commit=getattr(args, "base_commit", None),
2673
+ diff_hash=getattr(args, "diff_hash", None),
2674
+ record_migration=existing_path is not None,
2675
+ )
2676
+ except LedgerMigrationError as exc:
2677
+ return emit_error(
2678
+ f"ledger migration failed: {exc}",
2679
+ as_json=as_json,
2680
+ error_code="LEDGER_MIGRATION_REQUIRED",
2681
+ extra={
2682
+ "originalSchemaVersion": ledger.get("schemaVersion", 1),
2683
+ "targetSchemaVersion": LEDGER_SCHEMA_VERSION,
2684
+ "rerecordCommand": _deterministic_rerecord_command(
2685
+ args,
2686
+ files=files,
2687
+ project_root=project_root,
2688
+ ),
2689
+ },
2690
+ )
2691
+
2692
+ # Preserve top-level diffHash and all other existing fields (backward compatible).
2693
+ entry = {}
2694
+ prev = ledger["validations"].get(verification)
2695
+ if isinstance(prev, dict):
2696
+ entry.update(prev)
2697
+
2698
+ entry.update(
2699
+ {
2700
+ "status": status,
2701
+ "command": canonical_command(args.command),
2702
+ "runnerCommand": (
2703
+ str(getattr(args, "runner_command", None)).strip()
2704
+ if _nonempty_str(getattr(args, "runner_command", None))
2705
+ else str(args.command).strip()
2706
+ ),
2707
+ "evidence": args.evidence,
2708
+ "exitCode": args.exit_code,
2709
+ "durationMs": args.duration_ms,
2710
+ "inputsHash": inputs_hash,
2711
+ "inputsFiles": inputs_files,
2712
+ "finishedAt": now_iso(),
2713
+ }
2714
+ )
2715
+ # A fresh successful execution supersedes the prior target's fixback
2716
+ # invalidation. Carrying these projection-only fields forward would
2717
+ # make every rerecord permanently non-reusable.
2718
+ entry.pop("reusable", None)
2719
+ entry.pop("invalidation", None)
2720
+ metrics_raw = getattr(args, "metrics_json", None)
2721
+ metrics_file = getattr(args, "metrics_file", None)
2722
+ if metrics_file and metrics_raw is not None and str(metrics_raw).strip():
2723
+ return emit_error(
2724
+ "use only one of --metrics-json and --metrics-file",
2725
+ as_json=as_json,
2726
+ )
2727
+ if metrics_file:
2728
+ try:
2729
+ metrics_raw = Path(str(metrics_file)).expanduser().resolve().read_text(
2730
+ encoding="utf-8-sig"
2731
+ )
2732
+ except OSError as exc:
2733
+ return emit_error(f"invalid --metrics-file: {exc}", as_json=as_json)
2734
+ if metrics_raw is not None and str(metrics_raw).strip() != "":
2735
+ try:
2736
+ metrics_obj = json.loads(str(metrics_raw))
2737
+ except json.JSONDecodeError:
2738
+ return emit_error("invalid --metrics-json", as_json=as_json)
2739
+ if not isinstance(metrics_obj, dict):
2740
+ return emit_error("invalid --metrics-json", as_json=as_json)
2741
+ if contract_v2:
2742
+ # Typed metrics schemas are ledger v3 (contract-gated); legacy
2743
+ # changes keep the loose run/failures shape (zero regression).
2744
+ problems = validate_metrics(verification, metrics_obj)
2745
+ if problems:
2746
+ return emit_error(
2747
+ "invalid metrics: " + "; ".join(problems), as_json=as_json
2748
+ )
2749
+ entry["metrics"] = metrics_obj
2750
+ elif "metrics" in entry:
2751
+ # Fresh record without metrics must not keep stale metrics from prev.
2752
+ entry.pop("metrics", None)
2753
+ applicability_raw = getattr(args, "applicability", None)
2754
+ if applicability_raw:
2755
+ try:
2756
+ entry["applicability"] = build_applicability_entry(
2757
+ str(applicability_raw).strip(),
2758
+ reason=getattr(args, "applicability_reason", None),
2759
+ )
2760
+ except ValueError as exc:
2761
+ return emit_error(str(exc), as_json=as_json)
2762
+ effective_scope = (
2763
+ str(args.scope).strip()
2764
+ if args.scope is not None and str(args.scope).strip()
2765
+ else (
2766
+ str(
2767
+ target.get("coverageLevel")
2768
+ or target.get("requiredCoverage")
2769
+ or ""
2770
+ ).strip()
2771
+ if profile_input
2772
+ else ""
2773
+ )
2774
+ )
2775
+ if effective_scope:
2776
+ entry["scope"] = effective_scope
2777
+ # No default scope: recording an incremental run as broad "module" scope
2778
+ # would let can-reuse wrongly approve untested classes (D13 guardrail).
2779
+ # Missing scope → can-reuse treats unitTest as insufficient-evidence.
2780
+
2781
+ # v2 fields (cluster 2): algorithmVersion + coverage lattice + optional
2782
+ # toolchain/profile/environment hashes. v1 entries missing these are
2783
+ # conservatively invalidated by can-reuse (COM-002).
2784
+ entry["algorithmVersion"] = LEDGER_VERSION
2785
+ entry["targetIdentity"] = verification_target_identity(
2786
+ verification, target
2787
+ )
2788
+ coverage = getattr(args, "coverage", None)
2789
+ if not (_nonempty_str(coverage) and str(coverage).strip() in COVERAGE_RANK):
2790
+ coverage = derive_coverage(verification, effective_scope or None)
2791
+ entry["coverage"] = coverage
2792
+ for field, attr in (
2793
+ ("toolchainHash", "toolchain_hash"),
2794
+ ("profileHash", "profile_hash"),
2795
+ ("environmentHash", "environment_hash"),
2796
+ ("dbSchemaHash", "db_schema_hash"),
2797
+ ):
2798
+ val = getattr(args, attr, None)
2799
+ if field == "toolchainHash" and not _nonempty_str(val):
2800
+ val = default_toolchain_hash(project_root)
2801
+ elif field == "environmentHash" and not _nonempty_str(val):
2802
+ val = default_environment_hash(project_root)
2803
+ if _nonempty_str(val):
2804
+ entry[field] = str(val).strip()
2805
+ entry["commandSetHash"] = command_set_hash(args.command)
2806
+ tree_hash = product_tree_hash(project_root)
2807
+ if tree_hash:
2808
+ entry["productTreeHash"] = tree_hash
2809
+ dependency_lock_hash = lock_hash(project_root)
2810
+ if dependency_lock_hash:
2811
+ entry["lockHash"] = dependency_lock_hash
2812
+ # package verification: record build artifact + test-reuse provenance.
2813
+ if verification == "package":
2814
+ if _nonempty_str(getattr(args, "deploy_artifact", None)):
2815
+ entry["deployArtifact"] = str(args.deploy_artifact).strip()
2816
+ if _nonempty_str(getattr(args, "artifact_hash", None)):
2817
+ entry["sha256"] = str(args.artifact_hash).strip()
2818
+ entry["testsExecuted"] = bool(getattr(args, "tests_executed", False))
2819
+ if _nonempty_str(getattr(args, "tests_reused_from", None)):
2820
+ entry["testsReusedFrom"] = str(args.tests_reused_from).strip()
2821
+
2822
+ # C9: bind scenario IDs from --scenario-ids to this ledger entry.
2823
+ scenario_ids_raw = getattr(args, "scenario_ids", None)
2824
+ scenario_receipt_file = getattr(args, "scenario_receipt_file", None)
2825
+ if _nonempty_str(scenario_receipt_file) and not _nonempty_str(
2826
+ scenario_ids_raw
2827
+ ):
2828
+ return emit_error(
2829
+ "--scenario-receipt-file requires --scenario-ids",
2830
+ as_json=as_json,
2831
+ error_code="SCENARIO_IDS_REQUIRED",
2832
+ )
2833
+ if _nonempty_str(scenario_ids_raw):
2834
+ ids = [s.strip() for s in str(scenario_ids_raw).split(",") if s.strip()]
2835
+ if len(ids) != len(set(ids)):
2836
+ return emit_error(
2837
+ "scenario IDs must be unique",
2838
+ as_json=as_json,
2839
+ error_code="SCENARIO_ID_DUPLICATE",
2840
+ )
2841
+ if ids:
2842
+ entry["scenarioIds"] = ids
2843
+ receipt_file = scenario_receipt_file
2844
+ manifest_path = change_dir / "meta" / "scenario-manifest.json"
2845
+ manifest_schema = 0
2846
+ if manifest_path.is_file():
2847
+ try:
2848
+ manifest_probe = json.loads(
2849
+ manifest_path.read_text(encoding="utf-8-sig")
2850
+ )
2851
+ except (OSError, json.JSONDecodeError) as exc:
2852
+ return emit_error(
2853
+ f"invalid scenario manifest: {exc}",
2854
+ as_json=as_json,
2855
+ error_code="SCENARIO_MANIFEST_INVALID",
2856
+ )
2857
+ resolved_probe = resolve_scenario_manifest(manifest_probe)
2858
+ if not resolved_probe["ok"]:
2859
+ return emit_error(
2860
+ str(resolved_probe.get("message") or ""),
2861
+ as_json=as_json,
2862
+ error_code=str(resolved_probe["code"]),
2863
+ extra={"artifactType": "scenario_manifest"},
2864
+ )
2865
+ manifest_probe = resolved_probe["manifest"]
2866
+ raw_schema = (
2867
+ manifest_probe.get("schemaVersion")
2868
+ if isinstance(manifest_probe, dict)
2869
+ else None
2870
+ )
2871
+ if (
2872
+ isinstance(raw_schema, int)
2873
+ and not isinstance(raw_schema, bool)
2874
+ ):
2875
+ manifest_schema = raw_schema
2876
+ if manifest_schema >= 2 and not _nonempty_str(receipt_file):
2877
+ return emit_error(
2878
+ "scenario-manifest schemaVersion 2 requires "
2879
+ "--scenario-receipt-file; generate a skeleton with: "
2880
+ "harness_ledger.py scenario-receipt-template "
2881
+ f"--change-dir {change_dir} --scenario-ids "
2882
+ f"{','.join(ids)} --runner <name> --out "
2883
+ "runtime/scenario-receipt-<verification>.json",
2884
+ as_json=as_json,
2885
+ error_code="SCENARIO_RECEIPT_REQUIRED",
2886
+ extra={"template": "scenario-receipt-template"},
2887
+ )
2888
+ if _nonempty_str(receipt_file):
2889
+ resolved_receipt, receipt_candidates = _resolve_receipt_path(
2890
+ str(receipt_file), change_dir
2891
+ )
2892
+ if resolved_receipt is None:
2893
+ return emit_error(
2894
+ "scenario receipt not found; tried: "
2895
+ + ", ".join(str(c) for c in receipt_candidates),
2896
+ as_json=as_json,
2897
+ error_code="SCENARIO_RECEIPT_NOT_FOUND",
2898
+ extra={
2899
+ "triedPaths": [str(c) for c in receipt_candidates],
2900
+ "hint": (
2901
+ "--scenario-receipt-file accepts an absolute "
2902
+ "path, a CWD-relative path, or a path relative "
2903
+ "to --change-dir"
2904
+ ),
2905
+ },
2906
+ )
2907
+ try:
2908
+ receipt_payload = json.loads(
2909
+ resolved_receipt.read_text(encoding="utf-8-sig")
2910
+ )
2911
+ except (OSError, json.JSONDecodeError) as exc:
2912
+ return emit_error(
2913
+ f"invalid scenario receipt: {exc}",
2914
+ as_json=as_json,
2915
+ error_code="SCENARIO_RECEIPT_INVALID",
2916
+ )
2917
+ if not isinstance(receipt_payload, dict):
2918
+ return emit_error(
2919
+ "scenario receipt top level must be an object",
2920
+ as_json=as_json,
2921
+ error_code="SCENARIO_RECEIPT_INVALID",
2922
+ )
2923
+ scenario_result = validate_scenario_execution_receipt(
2924
+ change_dir=change_dir,
2925
+ scenario_ids=ids,
2926
+ receipt=receipt_payload,
2927
+ )
2928
+ if not scenario_result.get("ok"):
2929
+ return emit_error(
2930
+ str(scenario_result.get("error") or "invalid scenario receipt"),
2931
+ as_json=as_json,
2932
+ error_code=str(
2933
+ scenario_result.get("code")
2934
+ or "SCENARIO_RECEIPT_INVALID"
2935
+ ),
2936
+ )
2937
+ entry["scenarioReceipt"] = scenario_result["receipt"]
2938
+ entry["scenarioCoverage"] = scenario_result["coverage"]
2939
+ if status == "OK":
2940
+ passed = set(scenario_result["coverage"]["passed"])
2941
+ required = set(ids)
2942
+ missing = sorted(required - passed)
2943
+ if missing:
2944
+ return emit_error(
2945
+ "required scenarios were not executed successfully: "
2946
+ + ", ".join(missing),
2947
+ as_json=as_json,
2948
+ error_code="REQUIRED_SCENARIO_NOT_EXECUTED",
2949
+ extra={
2950
+ "missing": missing,
2951
+ "scenarioCoverage": scenario_result["coverage"],
2952
+ },
2953
+ )
2954
+ else:
2955
+ entry.pop("scenarioReceipt", None)
2956
+ entry.pop("scenarioCoverage", None)
2957
+ elif "scenarioIds" in entry:
2958
+ # Fresh record without scenario-ids must not keep stale ids from prev.
2959
+ entry.pop("scenarioIds", None)
2960
+ entry.pop("scenarioReceipt", None)
2961
+ entry.pop("scenarioCoverage", None)
2962
+
2963
+ target_identity = {
2964
+ "verification": verification,
2965
+ "command": entry.get("command"),
2966
+ "scope": entry.get("scope"),
2967
+ "coverage": entry.get("coverage"),
2968
+ "inputsHash": entry.get("inputsHash"),
2969
+ "toolchainHash": entry.get("toolchainHash"),
2970
+ "profileHash": entry.get("profileHash"),
2971
+ "environmentHash": entry.get("environmentHash"),
2972
+ "productTreeHash": entry.get("productTreeHash"),
2973
+ "commandSetHash": entry.get("commandSetHash"),
2974
+ "lockHash": entry.get("lockHash"),
2975
+ "dbSchemaHash": entry.get("dbSchemaHash"),
2976
+ }
2977
+ target_id = (
2978
+ verification
2979
+ + "-"
2980
+ + hashlib.sha256(
2981
+ json.dumps(
2982
+ target_identity,
2983
+ ensure_ascii=False,
2984
+ sort_keys=True,
2985
+ separators=(",", ":"),
2986
+ ).encode("utf-8")
2987
+ ).hexdigest()[:16]
2988
+ )
2989
+ target = {"id": target_id, "verification": verification, **entry}
2990
+ targets = ledger.setdefault("verificationTargets", {})
2991
+ if not isinstance(targets, dict):
2992
+ targets = {}
2993
+ ledger["verificationTargets"] = targets
2994
+ targets[target_id] = target
2995
+ ledger["validations"][verification] = {
2996
+ key: value
2997
+ for key, value in target.items()
2998
+ if key not in {"id", "verification"}
2999
+ }
3000
+ if "changeName" not in ledger:
3001
+ ledger["changeName"] = change_dir.name
3002
+ if "stateDir" not in ledger:
3003
+ ledger["stateDir"] = str(change_dir)
3004
+
3005
+ out_path = preferred_write_path(change_dir)
3006
+ write_ledger(out_path, ledger)
3007
+ try:
3008
+ upsert_verification_graph_node(
3009
+ change_dir,
3010
+ verification=verification,
3011
+ status=str(status),
3012
+ inputs_hash=str(inputs_hash),
3013
+ command=str(entry.get("command") or "") or None,
3014
+ toolchain_hash=(
3015
+ str(entry.get("toolchainHash")).strip()
3016
+ if _nonempty_str(entry.get("toolchainHash"))
3017
+ else None
3018
+ ),
3019
+ environment_hash=(
3020
+ str(entry.get("environmentHash")).strip()
3021
+ if _nonempty_str(entry.get("environmentHash"))
3022
+ else None
3023
+ ),
3024
+ db_schema_hash=(
3025
+ str(entry.get("dbSchemaHash")).strip()
3026
+ if _nonempty_str(entry.get("dbSchemaHash"))
3027
+ else None
3028
+ ),
3029
+ diff_hash=(
3030
+ str(ledger.get("diffHash")).strip()
3031
+ if _nonempty_str(ledger.get("diffHash"))
3032
+ else None
3033
+ ),
3034
+ target_identity=(
3035
+ str(entry.get("targetIdentity")).strip()
3036
+ if _nonempty_str(entry.get("targetIdentity"))
3037
+ else None
3038
+ ),
3039
+ )
3040
+ except (OSError, ValueError, TypeError):
3041
+ # Graph is advisory persistence; ledger write already succeeded.
3042
+ pass
3043
+ except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError) as exc:
3044
+ return emit_error(f"record failed: {exc}", as_json=as_json)
3045
+
3046
+ payload = {
3047
+ "ok": True,
3048
+ "action": "record",
3049
+ "verification": verification,
3050
+ "status": status,
3051
+ "inputsHash": inputs_hash,
3052
+ "inputsFiles": inputs_files,
3053
+ "ledger_path": str(out_path),
3054
+ "diffHash": ledger.get("diffHash"),
3055
+ "resolvedProjectRoot": str(project_root) if project_root else None,
3056
+ "scenarioCoverage": entry.get("scenarioCoverage"),
3057
+ "migrationReceipt": migration_receipt,
3058
+ }
3059
+ emit_compact_or_verbose(
3060
+ payload, as_json=as_json, verbose=verbose,
3061
+ compact_fn=_compact_record_payload,
3062
+ )
3063
+ return 0
3064
+
3065
+
3066
+ def cmd_scenario_receipt_template(args: argparse.Namespace) -> int:
3067
+ """Emit a schema-v2 receipt skeleton built from the scenario manifest.
3068
+
3069
+ Without this, agents had to read harness_ledger.py source to learn the
3070
+ receipt shape. The skeleton is already valid for `record`; the caller only
3071
+ has to correct `status` for any test that did not pass.
3072
+ """
3073
+ as_json = bool(args.json)
3074
+ change_dir = resolve_path(args.change_dir)
3075
+ ids = [s.strip() for s in str(args.scenario_ids).split(",") if s.strip()]
3076
+ if not ids:
3077
+ return emit_error(
3078
+ "--scenario-ids must list at least one scenario ID",
3079
+ as_json=as_json,
3080
+ error_code="SCENARIO_IDS_REQUIRED",
3081
+ )
3082
+ if len(ids) != len(set(ids)):
3083
+ return emit_error(
3084
+ "scenario IDs must be unique",
3085
+ as_json=as_json,
3086
+ error_code="SCENARIO_ID_DUPLICATE",
3087
+ )
3088
+ manifest_path = change_dir / "meta" / "scenario-manifest.json"
3089
+ try:
3090
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
3091
+ except FileNotFoundError:
3092
+ return emit_error(
3093
+ f"scenario manifest does not exist: {manifest_path}",
3094
+ as_json=as_json,
3095
+ error_code="SCENARIO_MANIFEST_MISSING",
3096
+ )
3097
+ except (OSError, json.JSONDecodeError) as exc:
3098
+ return emit_error(
3099
+ f"scenario manifest unreadable: {exc}",
3100
+ as_json=as_json,
3101
+ error_code="SCENARIO_MANIFEST_INVALID",
3102
+ )
3103
+ resolved = resolve_scenario_manifest(manifest)
3104
+ if not resolved["ok"]:
3105
+ return emit_error(
3106
+ str(resolved.get("message") or ""),
3107
+ as_json=as_json,
3108
+ error_code=str(resolved["code"]),
3109
+ extra={"artifactType": "scenario_manifest"},
3110
+ )
3111
+ manifest = resolved["manifest"]
3112
+ scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
3113
+ if not isinstance(scenarios, list):
3114
+ return emit_error(
3115
+ "scenario-manifest.json must contain a scenarios array",
3116
+ as_json=as_json,
3117
+ error_code="SCENARIO_MANIFEST_INVALID",
3118
+ )
3119
+ by_id = {
3120
+ str(item.get("id") or "").strip(): item
3121
+ for item in scenarios
3122
+ if isinstance(item, dict) and _nonempty_str(item.get("id"))
3123
+ }
3124
+ unknown = sorted(set(ids) - set(by_id))
3125
+ if unknown:
3126
+ return emit_error(
3127
+ "scenario IDs are not declared in scenario-manifest.json: "
3128
+ + ", ".join(unknown),
3129
+ as_json=as_json,
3130
+ error_code="SCENARIO_ID_UNKNOWN",
3131
+ )
3132
+ status = str(args.status).strip().upper()
3133
+ if status not in {"PASSED", "FAILED", "SKIPPED"}:
3134
+ return emit_error(
3135
+ "--status must be one of PASSED|FAILED|SKIPPED",
3136
+ as_json=as_json,
3137
+ error_code="SCENARIO_RECEIPT_INVALID",
3138
+ )
3139
+ attempt = int(args.attempt)
3140
+ if attempt < 1:
3141
+ return emit_error(
3142
+ "--attempt must be a positive integer",
3143
+ as_json=as_json,
3144
+ error_code="SCENARIO_RECEIPT_INVALID",
3145
+ )
3146
+
3147
+ identities: list[tuple[str, str, str]] = []
3148
+ incomplete: list[str] = []
3149
+ for scenario_id in ids:
3150
+ scenario = by_id[scenario_id]
3151
+ if any(
3152
+ not _nonempty_str(scenario.get(field))
3153
+ for field in ("executableTestId", "testFile", "testTitle")
3154
+ ):
3155
+ incomplete.append(scenario_id)
3156
+ continue
3157
+ identities.append(
3158
+ (
3159
+ str(scenario["executableTestId"]).strip(),
3160
+ str(scenario["testFile"]).strip(),
3161
+ str(scenario["testTitle"]).strip(),
3162
+ )
3163
+ )
3164
+ if incomplete:
3165
+ return emit_error(
3166
+ "scenarios are missing executableTestId/testFile/testTitle in the "
3167
+ "manifest: " + ", ".join(incomplete),
3168
+ as_json=as_json,
3169
+ error_code="SCENARIO_MANIFEST_INVALID",
3170
+ extra={"incomplete": incomplete},
3171
+ )
3172
+
3173
+ runner: dict[str, Any] = {"name": str(args.runner).strip()}
3174
+ if _nonempty_str(getattr(args, "runner_version", None)):
3175
+ runner["version"] = str(args.runner_version).strip()
3176
+ receipt = {
3177
+ "schemaVersion": 1,
3178
+ "runner": runner,
3179
+ "attempt": attempt,
3180
+ "declared": [test_id for test_id, _, _ in identities],
3181
+ "selected": [test_id for test_id, _, _ in identities],
3182
+ "collected": [
3183
+ {"testId": test_id, "file": file, "title": title}
3184
+ for test_id, file, title in identities
3185
+ ],
3186
+ "executed": [
3187
+ {
3188
+ "testId": test_id,
3189
+ "file": file,
3190
+ "title": title,
3191
+ "attempt": attempt,
3192
+ "status": status,
3193
+ }
3194
+ for test_id, file, title in identities
3195
+ ],
3196
+ }
3197
+ body = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
3198
+
3199
+ out_raw = getattr(args, "out", None)
3200
+ if not _nonempty_str(out_raw):
3201
+ sys.stdout.write(body)
3202
+ return 0
3203
+ out_path = Path(str(out_raw)).expanduser()
3204
+ if not out_path.is_absolute():
3205
+ out_path = change_dir / out_path
3206
+ out_path = out_path.resolve()
3207
+ try:
3208
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3209
+ out_path.write_text(body, encoding="utf-8", newline="\n")
3210
+ except OSError as exc:
3211
+ return emit_error(
3212
+ f"cannot write scenario receipt: {exc}",
3213
+ as_json=as_json,
3214
+ error_code="SCENARIO_RECEIPT_WRITE_FAILED",
3215
+ )
3216
+ payload = {
3217
+ "ok": True,
3218
+ "action": "scenario-receipt-template",
3219
+ "path": str(out_path),
3220
+ "scenarioIds": ids,
3221
+ }
3222
+ if as_json:
3223
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
3224
+ else:
3225
+ sys.stdout.write(f"{out_path}\n")
3226
+ return 0
3227
+
3228
+
3229
+ def cmd_diff_hash(args: argparse.Namespace) -> int:
3230
+ as_json = bool(args.json)
3231
+ repo_raw = getattr(args, "repo", None)
3232
+ base = getattr(args, "base", None)
3233
+ change_dir_raw = getattr(args, "change_dir", None)
3234
+ try:
3235
+ change_dir = resolve_path(change_dir_raw) if change_dir_raw else None
3236
+ repo = Path(repo_raw).expanduser().resolve() if repo_raw else Path.cwd().resolve()
3237
+ # Historical skill text passed ``--repo .`` even when the active change
3238
+ # ran in a linked worktree. Treat that shorthand like the default and
3239
+ # recover the authoritative execution root captured during Plan.
3240
+ repo_hint = str(repo_raw or "").strip().replace("\\", "/")
3241
+ if change_dir is not None and repo_hint in {"", ".", "./"}:
3242
+ inferred_repo = infer_execution_project_root(change_dir)
3243
+ if inferred_repo is not None:
3244
+ repo = inferred_repo
3245
+ elif declares_execution_worktree(change_dir):
3246
+ return emit_error(
3247
+ "change metadata declares an execution worktree that is missing or belongs "
3248
+ "to another repository",
3249
+ as_json=as_json,
3250
+ error_code="EXECUTION_WORKTREE_INVALID",
3251
+ )
3252
+ if change_dir is not None and _contract_is_v2(change_dir):
3253
+ resolved_base = str(base or "").strip()
3254
+ if not resolved_base:
3255
+ existing, _ = load_ledger(change_dir)
3256
+ if isinstance(existing, dict):
3257
+ resolved_base = str(existing.get("baseCommit") or "").strip()
3258
+ if not resolved_base:
3259
+ resolved_base = _immutable_change_base(change_dir, repo)
3260
+ if not resolved_base:
3261
+ raise ValueError(
3262
+ "immutable change base is missing; capture the Plan boundary "
3263
+ "or pass --base"
3264
+ )
3265
+ detail = compute_ownership_diff(
3266
+ repo,
3267
+ base=resolved_base,
3268
+ change_dir=change_dir,
3269
+ )
3270
+ payload = {
3271
+ "ok": True,
3272
+ "action": "diff-hash",
3273
+ "identityScope": "change-ownership",
3274
+ "algorithmVersion": "ownership-content-changeset-1",
3275
+ "base": resolved_base,
3276
+ "head": _git_text(repo, "rev-parse", "--verify", "HEAD"),
3277
+ **detail,
3278
+ }
3279
+ emit_json(payload, as_json=as_json)
3280
+ return 0
3281
+ diff_hash, meta = compute_diff_hash(
3282
+ repo,
3283
+ base=base,
3284
+ change_dir=str(change_dir) if change_dir is not None else None,
3285
+ )
3286
+ except (OSError, RuntimeError, ValueError) as exc:
3287
+ return emit_error(str(exc), as_json=as_json)
3288
+ payload = {"ok": True, "action": "diff-hash", "diffHash": diff_hash}
3289
+ payload.update(meta)
3290
+ emit_json(payload, as_json=as_json)
3291
+ return 0
3292
+
3293
+
3294
+ def build_parser() -> argparse.ArgumentParser:
3295
+ parser = argparse.ArgumentParser(
3296
+ prog="harness_ledger.py",
3297
+ description="Compute inputsHash and manage verification-ledger reuse",
3298
+ )
3299
+ parser.add_argument(
3300
+ "--json",
3301
+ action="store_true",
3302
+ help="emit machine-readable JSON on stdout",
3303
+ )
3304
+ sub = parser.add_subparsers(dest="command_name", required=True)
3305
+
3306
+ # --json 也注册到每个子命令(default=SUPPRESS),使 --json 可放在子命令之后
3307
+ # (skill / Gate 命令均把 --json 放最后),且不会用子命令默认值覆盖
3308
+ # 在子命令之前传入的顶层 --json=True。
3309
+ shared_json = argparse.ArgumentParser(add_help=False)
3310
+ shared_json.add_argument(
3311
+ "--json",
3312
+ action="store_true",
3313
+ default=argparse.SUPPRESS,
3314
+ )
3315
+
3316
+ p_hash = sub.add_parser("hash", parents=[shared_json], help="compute inputsHash for a file set")
3317
+ hash_files = p_hash.add_mutually_exclusive_group()
3318
+ hash_files.add_argument("--files", default=None, help="comma-separated source file paths")
3319
+ hash_files.add_argument("--files-from", default=None, help="UTF-8 newline-delimited source paths")
3320
+ p_hash.add_argument("--project", default=None, help="root for relative input paths")
3321
+ p_hash.set_defaults(func=cmd_hash)
3322
+
3323
+ p_reuse = sub.add_parser("can-reuse", parents=[shared_json], help="decide whether a verification can be reused")
3324
+ p_reuse.add_argument("--change-dir", required=True)
3325
+ p_reuse.add_argument(
3326
+ "--verification",
3327
+ required=True,
3328
+ help="built-in or build-profile v3 verificationGraph target",
3329
+ )
3330
+ p_reuse.add_argument(
3331
+ "--files",
3332
+ default=None,
3333
+ help="comma-separated source file paths for current inputsHash",
3334
+ )
3335
+ p_reuse.add_argument(
3336
+ "--files-from",
3337
+ default=None,
3338
+ help="UTF-8 newline-delimited source paths",
3339
+ )
3340
+ p_reuse.add_argument(
3341
+ "--project",
3342
+ default=None,
3343
+ help="project root containing .harness/config/build-profile.json (for --profile-input)",
3344
+ )
3345
+ p_reuse.add_argument(
3346
+ "--profile-input",
3347
+ default=None,
3348
+ help="expand verificationInputs.<key> globs from build-profile as the file set; "
3349
+ "unitTestFull 最终门禁用此展开依赖闭包,禁止用仅含 staged 文件的 --files 冒充",
3350
+ )
3351
+ p_reuse.add_argument(
3352
+ "--scope",
3353
+ default=None,
3354
+ help="optional requested scope (unitTest coverage check)",
3355
+ )
3356
+ p_reuse.add_argument(
3357
+ "--command",
3358
+ default=None,
3359
+ help="optional command to compare against ledger entry",
3360
+ )
3361
+ p_reuse.add_argument(
3362
+ "--toolchain-hash",
3363
+ default=None,
3364
+ help="optional toolchain hash to compare against ledger entry (UT-017)",
3365
+ )
3366
+ p_reuse.add_argument(
3367
+ "--profile-hash",
3368
+ default=None,
3369
+ help="optional profile hash to compare against ledger entry (UT-017)",
3370
+ )
3371
+ p_reuse.add_argument(
3372
+ "--environment-hash",
3373
+ default=None,
3374
+ help="optional environment hash to compare against ledger entry (UT-017)",
3375
+ )
3376
+ p_reuse.add_argument(
3377
+ "--db-schema-hash",
3378
+ default=None,
3379
+ help="optional DB schema hash to compare against ledger entry (H-9)",
3380
+ )
3381
+ p_reuse.add_argument("--product-tree-hash", default=None)
3382
+ p_reuse.add_argument("--command-set-hash", default=None)
3383
+ p_reuse.add_argument("--lock-hash", default=None)
3384
+ p_reuse.add_argument(
3385
+ "--verbose",
3386
+ action="store_true",
3387
+ help="emit full payload (default: compact ok/reuse/code)",
3388
+ )
3389
+ p_reuse.set_defaults(func=cmd_can_reuse)
3390
+
3391
+ p_record = sub.add_parser("record", parents=[shared_json], help="write validation result into ledger")
3392
+ p_record.add_argument("--change-dir", required=True)
3393
+ p_record.add_argument("--verification", required=True)
3394
+ p_record.add_argument("--status", required=True)
3395
+ p_record.add_argument("--command", required=True)
3396
+ p_record.add_argument(
3397
+ "--runner-command",
3398
+ default=None,
3399
+ help="actual launcher command; stored as execution metadata, not target identity",
3400
+ )
3401
+ p_record.add_argument("--exit-code", type=int, required=True)
3402
+ p_record.add_argument("--duration-ms", type=int, required=True)
3403
+ p_record_files = p_record.add_mutually_exclusive_group()
3404
+ p_record_files.add_argument("--files", default=None)
3405
+ p_record_files.add_argument("--files-from", default=None)
3406
+ p_record.add_argument("--evidence", required=True)
3407
+ p_record.add_argument(
3408
+ "--project",
3409
+ default=None,
3410
+ help="project root containing .harness/config/build-profile.json (for --profile-input)",
3411
+ )
3412
+ p_record.add_argument(
3413
+ "--profile-input",
3414
+ default=None,
3415
+ help="expand verificationInputs.<key> globs from build-profile as the file set",
3416
+ )
3417
+ p_record.add_argument(
3418
+ "--scope",
3419
+ default=None,
3420
+ help="optional scope (default module when absent on new entries)",
3421
+ )
3422
+ p_record.add_argument(
3423
+ "--coverage",
3424
+ default=None,
3425
+ help="optional coverage lattice value (incremental|module|module-am|full); derived when absent",
3426
+ )
3427
+ p_record.add_argument("--toolchain-hash", default=None)
3428
+ p_record.add_argument("--profile-hash", default=None)
3429
+ p_record.add_argument("--environment-hash", default=None)
3430
+ p_record.add_argument("--db-schema-hash", default=None)
3431
+ p_record.add_argument("--deploy-artifact", default=None, help="package: built artifact path")
3432
+ p_record.add_argument("--artifact-hash", default=None, help="package: artifact sha256")
3433
+ p_record.add_argument(
3434
+ "--tests-executed",
3435
+ type=lambda v: str(v).lower() in ("1", "true", "yes", "y"),
3436
+ default=False,
3437
+ help="package: whether tests ran in this package lifecycle",
3438
+ )
3439
+ p_record.add_argument(
3440
+ "--tests-reused-from",
3441
+ default=None,
3442
+ help="package: prior verifications reused when testsExecuted=false",
3443
+ )
3444
+ p_record.add_argument(
3445
+ "--metrics-json",
3446
+ default=None,
3447
+ help='structured counts, e.g. \'{"run":155,"failures":0}\' or \'{"total":3,"passed":3}\'',
3448
+ )
3449
+ p_record.add_argument(
3450
+ "--metrics-file",
3451
+ default=None,
3452
+ help="UTF-8 JSON file containing typed verification metrics",
3453
+ )
3454
+ p_record.add_argument(
3455
+ "--base-commit",
3456
+ default=None,
3457
+ help="ledger v3: base commit for identity (default: existing ledger value, else HEAD)",
3458
+ )
3459
+ p_record.add_argument(
3460
+ "--diff-hash",
3461
+ default=None,
3462
+ help="ledger v3: precomputed ownership diff hash (default: existing, else computed)",
3463
+ )
3464
+ p_record.add_argument(
3465
+ "--applicability",
3466
+ choices=("APPLICABLE", "NOT_APPLICABLE"),
3467
+ default=None,
3468
+ help="ledger v3: applicability of this verification to the change",
3469
+ )
3470
+ p_record.add_argument(
3471
+ "--applicability-reason",
3472
+ default=None,
3473
+ help="ledger v3: scope reason (required when --applicability NOT_APPLICABLE)",
3474
+ )
3475
+ p_record.add_argument(
3476
+ "--scenario-ids",
3477
+ default=None,
3478
+ help="comma-separated scenario IDs from scenario-manifest.json to bind to this entry",
3479
+ )
3480
+ p_record.add_argument(
3481
+ "--scenario-receipt-file",
3482
+ default=None,
3483
+ help="UTF-8 JSON runner receipt proving declared/selected/collected/executed tests",
3484
+ )
3485
+ p_record.add_argument(
3486
+ "--verbose",
3487
+ action="store_true",
3488
+ help="emit full payload (default: compact ok/action/verification/status)",
3489
+ )
3490
+ p_record.set_defaults(func=cmd_record)
3491
+
3492
+ p_diff = sub.add_parser(
3493
+ "diff-hash",
3494
+ parents=[shared_json],
3495
+ help="compute commit-invariant byte-level diff hash for a repo",
3496
+ )
3497
+ p_diff.add_argument(
3498
+ "--repo",
3499
+ default=None,
3500
+ help="repo root (default: change worktree when recorded, otherwise cwd)",
3501
+ )
3502
+ p_diff.add_argument("--base", default=None, help="base commit (default: root commit)")
3503
+ p_diff.add_argument(
3504
+ "--change-dir",
3505
+ default=None,
3506
+ help="change directory whose evidence/test-tracking.json contributes ignored tests",
3507
+ )
3508
+ p_diff.set_defaults(func=cmd_diff_hash)
3509
+
3510
+ p_receipt = sub.add_parser(
3511
+ "scenario-receipt-template",
3512
+ parents=[shared_json],
3513
+ help=(
3514
+ "emit a schema-v2 scenario execution receipt skeleton "
3515
+ "pre-filled from meta/scenario-manifest.json"
3516
+ ),
3517
+ )
3518
+ p_receipt.add_argument("--change-dir", required=True)
3519
+ p_receipt.add_argument(
3520
+ "--scenario-ids",
3521
+ required=True,
3522
+ help="comma-separated scenario IDs to include (must exist in the manifest)",
3523
+ )
3524
+ p_receipt.add_argument(
3525
+ "--runner",
3526
+ required=True,
3527
+ help="test runner name recorded in the receipt (e.g. vitest / maven-surefire)",
3528
+ )
3529
+ p_receipt.add_argument("--runner-version", default=None)
3530
+ p_receipt.add_argument("--attempt", type=int, default=1)
3531
+ p_receipt.add_argument(
3532
+ "--status",
3533
+ default="PASSED",
3534
+ help="per-test status written into executed[] (default: PASSED)",
3535
+ )
3536
+ p_receipt.add_argument(
3537
+ "--out",
3538
+ default=None,
3539
+ help=(
3540
+ "write the skeleton to this path instead of stdout; relative paths "
3541
+ "resolve against --change-dir (same rule as --scenario-receipt-file)"
3542
+ ),
3543
+ )
3544
+ p_receipt.set_defaults(func=cmd_scenario_receipt_template)
3545
+
3546
+ return parser
3547
+
3548
+
3549
+ def main(argv: list[str] | None = None) -> int:
3550
+ parser = build_parser()
3551
+ args = parser.parse_args(argv)
3552
+ return int(args.func(args))
3553
+
3554
+
3555
+ if __name__ == "__main__":
3556
+ raise SystemExit(main())