@hunter-harness/workflow-harness 0.4.0 → 0.4.2

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 +157 -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 +166 -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 +157 -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 +166 -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,1817 @@
1
+ #!/usr/bin/env python3
2
+ """Harness profile v3 — module/command/verification graph resolver, recommender and migrator.
3
+
4
+ Change cluster 1 of harness-deterministic-performance.
5
+
6
+ Spec §3.1:每个 verification 声明 command/argvTemplate/scope/inputs/coverage/
7
+ source/basis;持久 profile 只保存模板,不含具体 change-name/worktree 路径或已解析
8
+ overlay;运行期 resolve 结果写入 change runtime/session。
9
+
10
+ 兼容性:保留顶层 `verificationInputs`(派生自 commands.<key>.inputs)供旧调用方
11
+ 展开输入闭包;`verificationGraph.targets` 是动态验证目标的权威声明。
12
+
13
+ Python 3.10+ stdlib only. UTF-8 without BOM. Windows path friendly.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import datetime as dt
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import shlex
25
+ import shutil
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ SCRIPTS_DIR = Path(__file__).resolve().parent
32
+ if str(SCRIPTS_DIR) not in sys.path:
33
+ sys.path.insert(0, str(SCRIPTS_DIR))
34
+
35
+ import harness_paths # noqa: E402
36
+
37
+
38
+ if hasattr(sys.stdout, "reconfigure"):
39
+ sys.stdout.reconfigure(encoding="utf-8")
40
+ if hasattr(sys.stderr, "reconfigure"):
41
+ sys.stderr.reconfigure(encoding="utf-8")
42
+
43
+
44
+ SCHEMA_VERSION = 3
45
+ PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
46
+
47
+
48
+ def _legacy_command_has_shell_operator(command: str, *, platform: str) -> bool:
49
+ """Return whether direct argv migration would change shell semantics."""
50
+
51
+ quote: str | None = None
52
+ escaped = False
53
+ windows = platform == "windows"
54
+ for character in command:
55
+ if escaped:
56
+ escaped = False
57
+ continue
58
+ if not windows and character == "\\" and quote != "'":
59
+ escaped = True
60
+ continue
61
+ if character == '"' or (not windows and character == "'"):
62
+ quote = None if quote == character else (character if quote is None else quote)
63
+ continue
64
+ if quote is None and character in {"&", "|", "<", ">", ";"}:
65
+ return True
66
+ return False
67
+
68
+
69
+ def _parse_windows_command_line(command: str) -> list[str]:
70
+ """Parse CreateProcess-style argv without POSIX ``shlex`` semantics."""
71
+
72
+ argv: list[str] = []
73
+ length = len(command)
74
+ index = 0
75
+ while index < length:
76
+ while index < length and command[index] in {" ", "\t"}:
77
+ index += 1
78
+ if index >= length:
79
+ break
80
+ token: list[str] = []
81
+ token_started = False
82
+ quoted = False
83
+ while index < length:
84
+ character = command[index]
85
+ if character in {" ", "\t"} and not quoted:
86
+ break
87
+ if character == "\\":
88
+ slash_start = index
89
+ while index < length and command[index] == "\\":
90
+ index += 1
91
+ slash_count = index - slash_start
92
+ if index < length and command[index] == '"':
93
+ token.extend("\\" for _ in range(slash_count // 2))
94
+ if slash_count % 2:
95
+ token.append('"')
96
+ index += 1
97
+ else:
98
+ quoted = not quoted
99
+ token_started = True
100
+ index += 1
101
+ else:
102
+ token.extend("\\" for _ in range(slash_count))
103
+ token_started = True
104
+ continue
105
+ if character == '"':
106
+ quoted = not quoted
107
+ token_started = True
108
+ index += 1
109
+ continue
110
+ token.append(character)
111
+ token_started = True
112
+ index += 1
113
+ if quoted:
114
+ raise ValueError("PROFILE_MIGRATION_UNSAFE: unclosed Windows quote")
115
+ if token_started:
116
+ argv.append("".join(token))
117
+ while index < length and command[index] in {" ", "\t"}:
118
+ index += 1
119
+ return argv
120
+
121
+
122
+ def parse_legacy_service_command(
123
+ command: str,
124
+ *,
125
+ platform: str | None = None,
126
+ ) -> dict[str, Any]:
127
+ """Classify and parse a legacy service command without invoking a shell."""
128
+
129
+ normalized_platform = (
130
+ platform or ("windows" if os.name == "nt" else "posix")
131
+ ).strip().lower()
132
+ if normalized_platform not in {"windows", "posix"}:
133
+ return {
134
+ "ok": False,
135
+ "reasonCode": "PROFILE_INVALID",
136
+ "message": f"unsupported command platform: {normalized_platform}",
137
+ "platform": normalized_platform,
138
+ }
139
+ if not isinstance(command, str) or not command.strip():
140
+ return {
141
+ "ok": False,
142
+ "reasonCode": "PROFILE_INVALID",
143
+ "message": "legacy service command must be a non-empty string",
144
+ "platform": normalized_platform,
145
+ }
146
+ if _legacy_command_has_shell_operator(
147
+ command, platform=normalized_platform
148
+ ):
149
+ return {
150
+ "ok": False,
151
+ "reasonCode": "PROFILE_MIGRATION_UNSAFE",
152
+ "message": "legacy command contains shell operators",
153
+ "platform": normalized_platform,
154
+ }
155
+ try:
156
+ argv = (
157
+ _parse_windows_command_line(command)
158
+ if normalized_platform == "windows"
159
+ else shlex.split(command, posix=True)
160
+ )
161
+ except ValueError as exc:
162
+ return {
163
+ "ok": False,
164
+ "reasonCode": "PROFILE_MIGRATION_UNSAFE",
165
+ "message": str(exc),
166
+ "platform": normalized_platform,
167
+ }
168
+ if not argv or any("\0" in item for item in argv):
169
+ return {
170
+ "ok": False,
171
+ "reasonCode": "PROFILE_MIGRATION_UNSAFE",
172
+ "message": "legacy command cannot be represented as a safe argv array",
173
+ "platform": normalized_platform,
174
+ }
175
+ return {
176
+ "ok": True,
177
+ "reasonCode": None,
178
+ "message": "",
179
+ "platform": normalized_platform,
180
+ "argv": argv,
181
+ }
182
+
183
+
184
+ def normalize_service_start(
185
+ service_start: dict[str, Any] | None,
186
+ *,
187
+ platform: str | None = None,
188
+ ) -> dict[str, Any]:
189
+ """Normalize serviceStart while preserving unsafe legacy strings verbatim."""
190
+
191
+ source = json.loads(json.dumps(service_start or {}))
192
+ has_command = isinstance(source.get("command"), str) and bool(
193
+ source["command"].strip()
194
+ )
195
+ has_argv = isinstance(source.get("argvTemplate"), list)
196
+ if has_command and has_argv:
197
+ raise ValueError(
198
+ "PROFILE_MIGRATION_UNSAFE: serviceStart.command and "
199
+ "serviceStart.argvTemplate are mutually exclusive"
200
+ )
201
+ if has_argv:
202
+ argv = source["argvTemplate"]
203
+ if any(not isinstance(item, str) or "\0" in item for item in argv):
204
+ raise ValueError(
205
+ "PROFILE_INVALID: serviceStart.argvTemplate must contain strings"
206
+ )
207
+ source.pop("command", None)
208
+ source.pop("legacyWarning", None)
209
+ return source
210
+ if has_command:
211
+ parsed = parse_legacy_service_command(
212
+ source["command"], platform=platform
213
+ )
214
+ if parsed["ok"]:
215
+ source["argvTemplate"] = parsed["argv"]
216
+ source.pop("command", None)
217
+ source.pop("legacyWarning", None)
218
+ else:
219
+ source["legacyWarning"] = {
220
+ "reasonCode": parsed["reasonCode"],
221
+ "platform": parsed["platform"],
222
+ "message": parsed["message"],
223
+ }
224
+ return source
225
+ source.pop("command", None)
226
+ source["argvTemplate"] = []
227
+ source.pop("legacyWarning", None)
228
+ return source
229
+
230
+
231
+ def resolve_service_argv(
232
+ service_start: dict[str, Any],
233
+ replacements: dict[str, str] | None = None,
234
+ *,
235
+ platform: str | None = None,
236
+ ) -> list[str]:
237
+ """Resolve placeholders per argv element; never reparse a joined string."""
238
+
239
+ normalized = normalize_service_start(service_start, platform=platform)
240
+ argv = normalized.get("argvTemplate")
241
+ if not isinstance(argv, list):
242
+ warning = normalized.get("legacyWarning") or {}
243
+ reason = warning.get("reasonCode") or "PROFILE_MIGRATION_UNSAFE"
244
+ raise ValueError(f"{reason}: legacy service command requires review")
245
+ resolved: list[str] = []
246
+ for element in argv:
247
+ value = element
248
+ for placeholder, replacement in (replacements or {}).items():
249
+ value = value.replace(placeholder, replacement)
250
+ resolved.append(value)
251
+ return resolved
252
+
253
+
254
+ def recommend(project: Path) -> dict[str, Any]:
255
+ """Return a reviewable profile recommendation without writing it."""
256
+ project = Path(project).resolve()
257
+ project_type = detect_project_type(project)
258
+ modules: list[dict[str, Any]] = []
259
+ targets: list[dict[str, Any]] = []
260
+ if project_type == "java-maven":
261
+ reactor = [item for item in find_reactor_modules(project, DEFAULT_EXCLUDED_ROOTS) if item != "."]
262
+ modules = [
263
+ {
264
+ "id": module,
265
+ "path": module,
266
+ "type": "java-maven",
267
+ "ownedPaths": [f"{module}/src/**", f"{module}/pom.xml"],
268
+ "dependsOn": [],
269
+ }
270
+ for module in sorted(reactor)
271
+ ]
272
+ if modules:
273
+ targets.append(
274
+ {
275
+ "id": "unitTest-module",
276
+ "verification": "unitTestFull",
277
+ "level": "module",
278
+ "command": "mvn test -pl {modules} -am -o",
279
+ "inputs": [f"{module['path']}/src/**" for module in modules],
280
+ "sharedConsumer": False,
281
+ }
282
+ )
283
+ targets.append(
284
+ {
285
+ "id": "unitTest-candidate",
286
+ "verification": "unitTestFull",
287
+ "level": "candidate",
288
+ "command": "mvn -f pom.xml test -o",
289
+ "inputs": ["pom.xml", "**/src/**"],
290
+ "sharedConsumer": True,
291
+ }
292
+ )
293
+ elif project_type == "node":
294
+ try:
295
+ package = read_json(project / "package.json")
296
+ except (OSError, json.JSONDecodeError):
297
+ package = {}
298
+ workspaces = package.get("workspaces", []) if isinstance(package, dict) else []
299
+ if isinstance(workspaces, dict):
300
+ workspaces = workspaces.get("packages", [])
301
+ paths: set[str] = set()
302
+ for pattern in workspaces if isinstance(workspaces, list) else []:
303
+ for match in project.glob(str(pattern)):
304
+ if match.is_dir() and (match / "package.json").is_file():
305
+ paths.add(match.relative_to(project).as_posix())
306
+ modules = [
307
+ {
308
+ "id": path.replace("/", "-"),
309
+ "path": path,
310
+ "type": "node-workspace",
311
+ "ownedPaths": [f"{path}/src/**", f"{path}/test/**"],
312
+ "dependsOn": [],
313
+ }
314
+ for path in sorted(paths)
315
+ ]
316
+ targets.extend(
317
+ {
318
+ "id": f"{module['id']}-test",
319
+ "verification": "unitTest",
320
+ "level": "module",
321
+ "command": f"npm test -w {module['path']}",
322
+ "inputs": module["ownedPaths"],
323
+ "sharedConsumer": False,
324
+ }
325
+ for module in modules
326
+ )
327
+ node_commands = _node_commands(project)
328
+ if "unitTestFull" in node_commands:
329
+ targets.append(
330
+ {
331
+ "id": "workspace-candidate",
332
+ "verification": "unitTestFull",
333
+ "level": "candidate",
334
+ "command": node_commands["unitTestFull"]["command"],
335
+ "inputs": node_commands["unitTestFull"]["inputs"],
336
+ "sharedConsumer": True,
337
+ }
338
+ )
339
+ boundaries_proven = bool(modules) or project_type in {"node", "java-maven"}
340
+ return {
341
+ "ok": True,
342
+ "code": "PROFILE_RECOMMENDED",
343
+ "schemaVersion": SCHEMA_VERSION,
344
+ "projectType": project_type,
345
+ "modules": modules,
346
+ "targets": targets,
347
+ "moduleGraph": {
348
+ "modules": modules,
349
+ "boundariesProven": boundaries_proven,
350
+ "needsReview": not boundaries_proven,
351
+ },
352
+ "needsReview": not boundaries_proven,
353
+ "applied": False,
354
+ }
355
+
356
+
357
+ def project_profile_v3(
358
+ profile: dict[str, Any],
359
+ *,
360
+ platform: str | None = None,
361
+ ) -> dict[str, Any]:
362
+ """Compatibility projection for review before a protected v3 update."""
363
+ projected = json.loads(json.dumps(profile))
364
+ source_version = projected.get("schemaVersion")
365
+ if source_version == SCHEMA_VERSION:
366
+ return projected
367
+ if source_version != 2:
368
+ return projected
369
+ projected["schemaVersion"] = SCHEMA_VERSION
370
+ commands = projected.get("commands") if isinstance(projected.get("commands"), dict) else {}
371
+ legacy_verification_inputs = {
372
+ key: list(patterns)
373
+ for key, patterns in (
374
+ projected.get("verificationInputs")
375
+ if isinstance(projected.get("verificationInputs"), dict)
376
+ else {}
377
+ ).items()
378
+ if isinstance(key, str) and isinstance(patterns, list)
379
+ }
380
+ projected.setdefault(
381
+ "moduleGraph",
382
+ {"modules": [], "boundariesProven": False, "needsReview": True},
383
+ )
384
+ projected["commandGraph"] = {
385
+ "targets": [
386
+ {
387
+ "id": key,
388
+ "verification": key,
389
+ "command": command.get("command"),
390
+ "scope": command.get("scope"),
391
+ "inputs": list(command.get("inputs") or []),
392
+ "coverage": command.get("coverage"),
393
+ "source": command.get("source"),
394
+ }
395
+ for key, command in sorted(commands.items())
396
+ if isinstance(command, dict)
397
+ ]
398
+ }
399
+ projected["migration"] = {
400
+ "fromSchemaVersion": 2,
401
+ "projected": True,
402
+ "needsReview": True,
403
+ "protectedUserCommands": sorted(
404
+ key
405
+ for key, command in commands.items()
406
+ if isinstance(command, dict) and command.get("source") == "user"
407
+ ),
408
+ }
409
+ projected["serviceStart"] = normalize_service_start(
410
+ projected.get("serviceStart")
411
+ if isinstance(projected.get("serviceStart"), dict)
412
+ else None,
413
+ platform=platform,
414
+ )
415
+ legacy_warning = projected["serviceStart"].get("legacyWarning")
416
+ if isinstance(legacy_warning, dict):
417
+ projected["migration"]["needsReview"] = True
418
+ projected["migration"]["serviceStartWarning"] = legacy_warning
419
+ projected["defaultsFingerprint"] = profile_defaults_fingerprint()
420
+ _derive_verification_inputs(projected)
421
+ projected["verificationInputs"] = {
422
+ **legacy_verification_inputs,
423
+ **projected["verificationInputs"],
424
+ }
425
+ _derive_verification_graph(projected)
426
+ return projected
427
+
428
+
429
+ def audit_profile(project: Path) -> dict[str, Any]:
430
+ path = Path(project).resolve() / PROFILE_REL
431
+ if not path.is_file():
432
+ return {
433
+ "ok": False,
434
+ "code": "PROFILE_MISSING",
435
+ "needsReview": True,
436
+ "issues": ["build-profile.json missing"],
437
+ }
438
+ try:
439
+ profile = read_json(path)
440
+ except (OSError, json.JSONDecodeError) as exc:
441
+ return {
442
+ "ok": False,
443
+ "code": "PROFILE_INVALID",
444
+ "needsReview": True,
445
+ "issues": [str(exc)],
446
+ }
447
+ issues: list[str] = []
448
+ if profile.get("schemaVersion") != SCHEMA_VERSION:
449
+ issues.append("profile schema requires migration")
450
+ module_graph = profile.get("moduleGraph")
451
+ if not isinstance(module_graph, dict) or module_graph.get("boundariesProven") is not True:
452
+ issues.append("module boundaries are not proven")
453
+ if not isinstance(profile.get("commandGraph"), dict):
454
+ issues.append("command graph missing")
455
+ needs_review = bool(issues)
456
+ return {
457
+ "ok": not needs_review,
458
+ "code": "PROFILE_REVIEW_REQUIRED" if needs_review else "PROFILE_AUDITED",
459
+ "needsReview": needs_review,
460
+ "issues": issues,
461
+ }
462
+
463
+ # 排除策略(spec §3.1):所有路径必须位于 project root,排除以下目录。
464
+ # 兄弟 worktree、构建产物、依赖目录、缓存一律不进入 verification inputs。
465
+ DEFAULT_EXCLUDED_ROOTS: tuple[str, ...] = (
466
+ ".git",
467
+ ".harness",
468
+ ".worktrees",
469
+ ".claude/worktrees",
470
+ ".codex/worktrees",
471
+ ".cursor/worktrees",
472
+ ".codebuddy/worktrees",
473
+ ".codeium/worktrees",
474
+ "target",
475
+ "build",
476
+ "dist",
477
+ "node_modules",
478
+ ".gradle",
479
+ ".idea",
480
+ ".vscode",
481
+ "__pycache__",
482
+ ".pytest_cache",
483
+ ".cache",
484
+ )
485
+
486
+
487
+ def profile_defaults_fingerprint() -> str:
488
+ """Fingerprint generated defaults so old profiles are refreshed safely."""
489
+ payload = {
490
+ "schemaVersion": SCHEMA_VERSION,
491
+ "excludedRoots": list(DEFAULT_EXCLUDED_ROOTS),
492
+ }
493
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
494
+ return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
495
+
496
+ # 覆盖层级(spec §3.2):unitTest ⊂ unitTestFull;package 独立;submit 复用 unitTestFull。
497
+ VERIFICATION_KEYS: tuple[str, ...] = (
498
+ "compile",
499
+ "unitTest",
500
+ "unitTestFull",
501
+ "install",
502
+ "package",
503
+ )
504
+
505
+ VALID_SOURCES = ("detected", "user")
506
+ TARGET_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
507
+
508
+
509
+ def now_iso() -> str:
510
+ return dt.datetime.now().astimezone().isoformat(timespec="seconds")
511
+
512
+
513
+ def read_json(path: Path) -> Any:
514
+ # utf-8-sig 兼容可能残留的 BOM(与 harness_ledger.py 保持一致)。
515
+ return json.loads(path.read_text(encoding="utf-8-sig"))
516
+
517
+
518
+ def write_json(path: Path, data: Any) -> None:
519
+ path.parent.mkdir(parents=True, exist_ok=True)
520
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
521
+ # 强制 LF,UTF-8 无 BOM(spec §3.4 字节级指纹一致性)。
522
+ # 原子写 temp+os.replace:崩溃后不留半写文件(与 runtime-helpers.mjs writeJsonUtf8NoBom 一致)。
523
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
524
+ try:
525
+ tmp.write_text(text, encoding="utf-8", newline="\n")
526
+ os.replace(tmp, path)
527
+ except BaseException:
528
+ tmp.unlink(missing_ok=True)
529
+ raise
530
+
531
+
532
+ def sha256_file(path: Path) -> str:
533
+ if not path.is_file():
534
+ return ""
535
+ h = hashlib.sha256()
536
+ with path.open("rb") as fh:
537
+ for chunk in iter(lambda: fh.read(65536), b""):
538
+ h.update(chunk)
539
+ return h.hexdigest()
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # 排除策略与路径逃逸校验
544
+ # ---------------------------------------------------------------------------
545
+
546
+ def _excluded_segments(excluded: tuple[str, ...] | list[str]) -> tuple[tuple[str, ...], ...]:
547
+ return tuple(tuple(e.split("/")) for e in excluded)
548
+
549
+
550
+ def is_path_excluded(rel_path: str, excluded: tuple[str, ...] | list[str]) -> bool:
551
+ """rel_path 是否落在某个 excluded root 下。
552
+
553
+ rel_path 用 posix 斜杠;支持多段 excluded root(如 .claude/worktrees)。
554
+ """
555
+ parts = Path(rel_path).parts
556
+ for segs in _excluded_segments(excluded):
557
+ if len(parts) >= len(segs) and parts[: len(segs)] == segs:
558
+ return True
559
+ return False
560
+
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # 探测
564
+ # ---------------------------------------------------------------------------
565
+
566
+ def detect_project_type(project: Path) -> str:
567
+ if (project / "pom.xml").is_file():
568
+ return "java-maven"
569
+ if any(
570
+ (project / marker).is_file()
571
+ for marker in (
572
+ "build.gradle",
573
+ "build.gradle.kts",
574
+ "settings.gradle",
575
+ "settings.gradle.kts",
576
+ )
577
+ ):
578
+ return "java-gradle"
579
+ if (project / "package.json").is_file():
580
+ return "node"
581
+ return "unknown"
582
+
583
+
584
+ def discover_nested_components(
585
+ project: Path, excluded: tuple[str, ...] | list[str]
586
+ ) -> list[str]:
587
+ """Return deterministic type:path labels for supported nested projects."""
588
+ markers = {
589
+ "pyproject.toml": "python",
590
+ "package.json": "node",
591
+ "pom.xml": "java-maven",
592
+ "build.gradle": "java-gradle",
593
+ "build.gradle.kts": "java-gradle",
594
+ }
595
+ found: list[tuple[str, str]] = []
596
+ for marker, kind in markers.items():
597
+ for path in project.rglob(marker):
598
+ if not path.is_file():
599
+ continue
600
+ rel = path.relative_to(project)
601
+ if rel.parent == Path(".") or is_path_excluded(rel.as_posix(), excluded):
602
+ continue
603
+ found.append((rel.parent.as_posix(), kind))
604
+ return [f"{kind}:{rel}" for rel, kind in sorted(set(found))]
605
+
606
+
607
+ def which_tool(name: str) -> str:
608
+ found = shutil.which(name)
609
+ if found:
610
+ return str(Path(found).resolve())
611
+ if os.name == "nt":
612
+ for ext in (".cmd", ".bat", ".exe"):
613
+ found = shutil.which(name + ext)
614
+ if found:
615
+ return str(Path(found).resolve())
616
+ return ""
617
+
618
+
619
+ def find_reactor_modules(project: Path, excluded: tuple[str, ...] | list[str]) -> list[str]:
620
+ """发现当前 checkout 内含 pom.xml 的模块(repo-relative,排序去重)。
621
+
622
+ 兄弟 worktree、target/build/node_modules 等排除目录内的 POM 一律排除
623
+ (UT-003/UT-004)。root pom → ".";子模块 → "module-a"。
624
+ """
625
+ project = project.resolve()
626
+ modules: set[str] = set()
627
+ for pom in project.rglob("pom.xml"):
628
+ if not pom.is_file():
629
+ continue
630
+ rel = pom.relative_to(project)
631
+ if is_path_excluded(rel.as_posix(), excluded):
632
+ continue
633
+ parent = rel.parent
634
+ mod = "." if str(parent) == "." else parent.as_posix()
635
+ modules.add(mod)
636
+ return sorted(modules)
637
+
638
+
639
+ def _cmd_inputs(
640
+ modules: list[str], *, pom: bool, main: bool, test: bool
641
+ ) -> list[str]:
642
+ """按 reactor modules 生成 verification inputs(排序去重)。"""
643
+ result: list[str] = []
644
+ for m in modules:
645
+ prefix = "" if m == "." else f"{m}/"
646
+ if pom:
647
+ result.append(f"{prefix}pom.xml")
648
+ if main:
649
+ result.append(f"{prefix}src/main/**")
650
+ if test:
651
+ result.append(f"{prefix}src/test/**")
652
+ return sorted(set(result))
653
+
654
+
655
+ def _java_commands(reactor_modules: list[str], pom_hash: str) -> dict[str, Any]:
656
+ basis = {"reactorModules": list(reactor_modules), "pomHash": pom_hash}
657
+ full_inputs = _cmd_inputs(reactor_modules, pom=True, main=True, test=True)
658
+ return {
659
+ "compile": {
660
+ "command": "mvn -f pom.xml compile -o -q",
661
+ "argvTemplate": ["mvn", "-f", "pom.xml", "compile", "-o", "-q"],
662
+ "scope": "module",
663
+ "inputs": _cmd_inputs(reactor_modules, pom=True, main=True, test=False),
664
+ "coverage": "compile",
665
+ "dependsOn": [],
666
+ "requiredCapabilities": ["java", "maven"],
667
+ "source": "detected",
668
+ "basis": dict(basis),
669
+ },
670
+ "unitTest": {
671
+ "command": "mvn -f pom.xml test -Dtest={testClasses} -o",
672
+ "argvTemplate": ["mvn", "-f", "pom.xml", "test", "-Dtest={testClasses}", "-o"],
673
+ "scope": "incremental",
674
+ "inputs": _cmd_inputs(reactor_modules, pom=True, main=False, test=True),
675
+ "coverage": "unitTest",
676
+ "dependsOn": ["compile"],
677
+ "requiredCapabilities": ["java", "maven"],
678
+ "source": "detected",
679
+ "basis": dict(basis),
680
+ },
681
+ "unitTestFull": {
682
+ "command": "mvn -f pom.xml test -o",
683
+ "argvTemplate": ["mvn", "-f", "pom.xml", "test", "-o"],
684
+ "scope": "full",
685
+ "inputs": full_inputs,
686
+ "coverage": "unitTestFull",
687
+ "dependsOn": ["compile"],
688
+ "requiredCapabilities": ["java", "maven"],
689
+ "source": "detected",
690
+ "basis": dict(basis),
691
+ },
692
+ "install": {
693
+ "command": "mvn install -pl {modules} -am -DskipTests -nsu",
694
+ "argvTemplate": ["mvn", "install", "-pl", "{modules}", "-am", "-DskipTests", "-nsu"],
695
+ "scope": "module-am",
696
+ "inputs": _cmd_inputs(reactor_modules, pom=True, main=False, test=False),
697
+ "coverage": "install",
698
+ "dependsOn": ["compile"],
699
+ "requiredCapabilities": ["java", "maven"],
700
+ "source": "detected",
701
+ "basis": dict(basis),
702
+ },
703
+ "package": {
704
+ "command": "mvn -f pom.xml clean package '-Dmaven.test.skip=true'",
705
+ "argvTemplate": ["mvn", "-f", "pom.xml", "clean", "package", "-Dmaven.test.skip=true"],
706
+ "scope": "module",
707
+ "inputs": _cmd_inputs(reactor_modules, pom=True, main=True, test=True),
708
+ "coverage": "package",
709
+ "dependsOn": ["compile"],
710
+ "requiredCapabilities": ["java", "maven"],
711
+ "source": "detected",
712
+ "basis": dict(basis),
713
+ },
714
+ }
715
+
716
+
717
+ def _gradle_commands(project: Path) -> dict[str, Any]:
718
+ if (project / "gradlew").is_file():
719
+ executable = "./gradlew"
720
+ required_capabilities = ["java"]
721
+ elif (project / "gradlew.bat").is_file():
722
+ executable = "gradlew.bat"
723
+ required_capabilities = ["java"]
724
+ else:
725
+ executable = "gradle"
726
+ required_capabilities = ["java", "gradle"]
727
+ build_inputs = [
728
+ "build.gradle",
729
+ "build.gradle.kts",
730
+ "settings.gradle",
731
+ "settings.gradle.kts",
732
+ "gradle.properties",
733
+ "gradle/wrapper/**",
734
+ "gradlew",
735
+ "gradlew.bat",
736
+ ]
737
+ full_inputs = build_inputs + ["src/main/**", "src/test/**"]
738
+ basis = {
739
+ "buildSystem": "gradle",
740
+ "wrapper": executable != "gradle",
741
+ }
742
+
743
+ def command(
744
+ task: str,
745
+ *,
746
+ scope: str,
747
+ inputs: list[str],
748
+ coverage: str,
749
+ depends_on: list[str],
750
+ extra: list[str] | None = None,
751
+ ) -> dict[str, Any]:
752
+ argv = [executable, task, *(extra or []), "--offline"]
753
+ return {
754
+ "command": " ".join(argv),
755
+ "argvTemplate": argv,
756
+ "scope": scope,
757
+ "inputs": inputs,
758
+ "coverage": coverage,
759
+ "dependsOn": depends_on,
760
+ "requiredCapabilities": list(required_capabilities),
761
+ "source": "detected",
762
+ "basis": dict(basis),
763
+ }
764
+
765
+ return {
766
+ "compile": command(
767
+ "classes",
768
+ scope="module",
769
+ inputs=build_inputs + ["src/main/**"],
770
+ coverage="compile",
771
+ depends_on=[],
772
+ ),
773
+ "unitTest": command(
774
+ "test",
775
+ scope="incremental",
776
+ inputs=build_inputs + ["src/main/**", "src/test/**"],
777
+ coverage="unitTest",
778
+ depends_on=["compile"],
779
+ extra=["--tests", "{testClasses}"],
780
+ ),
781
+ "unitTestFull": command(
782
+ "test",
783
+ scope="full",
784
+ inputs=full_inputs,
785
+ coverage="unitTestFull",
786
+ depends_on=["compile"],
787
+ ),
788
+ "package": command(
789
+ "build",
790
+ scope="module",
791
+ inputs=full_inputs,
792
+ coverage="package",
793
+ depends_on=["compile"],
794
+ extra=["-x", "test"],
795
+ ),
796
+ }
797
+
798
+
799
+ def _node_commands(project: Path) -> dict[str, Any]:
800
+ """commands for a node project: unitTestFull = `npm run check` (or `npm test`)
801
+ with an input closure covering TS sources/tests + config (+ harness Python/.mjs
802
+ when the project dogfoods harness). Lets can-reuse --profile-input unitTestFull
803
+ reuse a green full check instead of forcing insufficient-evidence."""
804
+ pkg = project / "package.json"
805
+ if not pkg.is_file():
806
+ return {}
807
+ try:
808
+ data = json.loads(pkg.read_text(encoding="utf-8-sig"))
809
+ except (OSError, json.JSONDecodeError):
810
+ return {}
811
+ scripts = data.get("scripts") if isinstance(data, dict) else None
812
+ if not isinstance(scripts, dict):
813
+ return {}
814
+ script_key = "check" if "check" in scripts else ("test" if "test" in scripts else None)
815
+ if not script_key:
816
+ return {}
817
+ full_cmd = scripts[script_key]
818
+ declared_package_manager = data.get("packageManager")
819
+ package_manager = (
820
+ "pnpm"
821
+ if (
822
+ isinstance(declared_package_manager, str)
823
+ and declared_package_manager.split("@", 1)[0] == "pnpm"
824
+ )
825
+ or (project / "pnpm-lock.yaml").is_file()
826
+ else "npm"
827
+ )
828
+ # Precise globs (avoid node_modules/** which would make the inputs hash unstable).
829
+ inputs = [
830
+ "package.json",
831
+ "package-lock.json",
832
+ "pnpm-lock.yaml",
833
+ "tsconfig.json",
834
+ "tsconfig.*.json",
835
+ "vitest.config.*",
836
+ "eslint.config.*",
837
+ "src/**/*.ts",
838
+ "src/**/*.tsx",
839
+ "test/**/*.ts",
840
+ "test/**/*.tsx",
841
+ "tests/**/*.ts",
842
+ "tests/**/*.tsx",
843
+ "packages/*/src/**/*.ts",
844
+ "packages/*/src/**/*.tsx",
845
+ "packages/*/test/**/*.ts",
846
+ "packages/*/test/**/*.tsx",
847
+ "packages/*/tests/**/*.ts",
848
+ "packages/*/tests/**/*.tsx",
849
+ "apps/*/src/**/*.ts",
850
+ "apps/*/src/**/*.tsx",
851
+ "apps/*/test/**/*.ts",
852
+ "apps/*/test/**/*.tsx",
853
+ "apps/*/tests/**/*.ts",
854
+ "apps/*/tests/**/*.tsx",
855
+ ]
856
+ # harness dogfood: canonical Python + .mjs sources feed npm run check (vitest
857
+ # imports harness-test/scripts; smoke:pack runs sync-harness.mjs).
858
+ if (project / "harness").is_dir():
859
+ inputs.extend(
860
+ [
861
+ "harness/scripts/*.py",
862
+ "harness/harness-test/scripts/*.mjs",
863
+ "harness/harness-test/scripts/tests/*.mjs",
864
+ "scripts/*.mjs",
865
+ ]
866
+ )
867
+ return {
868
+ "unitTestFull": {
869
+ "command": full_cmd,
870
+ "argvTemplate": full_cmd.split(),
871
+ "scope": "full",
872
+ "inputs": inputs,
873
+ "coverage": "unitTestFull",
874
+ "dependsOn": [],
875
+ "requiredCapabilities": ["node", package_manager],
876
+ "source": "detected",
877
+ "basis": {
878
+ "packageScript": script_key,
879
+ "packageManager": package_manager,
880
+ },
881
+ }
882
+ }
883
+
884
+
885
+ def empty_profile_skeleton(excluded: tuple[str, ...] | list[str]) -> dict[str, Any]:
886
+ return {
887
+ "schemaVersion": SCHEMA_VERSION,
888
+ "defaultsFingerprint": profile_defaults_fingerprint(),
889
+ "detectedAt": "",
890
+ "projectType": "unknown",
891
+ "toolPaths": {"node": "", "mvn": "", "gradle": "", "npm": "", "pnpm": ""},
892
+ "excludedRoots": list(excluded),
893
+ "commands": {},
894
+ # 兼容字段:由 commands 派生,供旧调用方展开输入闭包。
895
+ "verificationInputs": {},
896
+ "verificationGraph": {
897
+ "schemaVersion": 1,
898
+ "source": "detected",
899
+ "candidateTarget": None,
900
+ "targets": {},
901
+ },
902
+ "serviceStart": {
903
+ "argvTemplate": [],
904
+ "healthUrl": "",
905
+ "startTimeoutSec": 120,
906
+ "inputFiles": [],
907
+ "source": "detected",
908
+ "profile": "",
909
+ "overlayPath": "",
910
+ },
911
+ "identifier": {
912
+ "pattern": r"^[A-Za-z][A-Za-z0-9_-]*$",
913
+ "maxLength": 64,
914
+ "prefix": "",
915
+ },
916
+ "knownPreexistingErrors": [],
917
+ "shellQuirks": [],
918
+ "fingerprint": {"mvnVersion": "", "nodeVersion": "", "pomHash": ""},
919
+ "testTracking": {
920
+ "source": "detected",
921
+ "mode": "force-track-touched",
922
+ "paths": [],
923
+ "staleTestPolicy": "safe-repair",
924
+ "forbidTemporaryExclusion": True,
925
+ },
926
+ }
927
+
928
+
929
+ def load_profile(project: Path) -> dict[str, Any] | None:
930
+ """C7: 分层读取 — 先读 common_root 的 build-profile.json,再叠加 execution root 的 override。
931
+
932
+ common_root 是 worktree 共享的主项目根(git common dir 的父目录);
933
+ execution root 是当前工作目录(可能是 linked worktree)。
934
+ override 合并策略:execution 的 buildCommands 覆盖 common 的同名 key;
935
+ common 独有的 key 保留。
936
+ """
937
+ project = Path(project).resolve()
938
+ common = harness_paths.common_root(project)
939
+ common_path = common / PROFILE_REL
940
+ exec_path = project / PROFILE_REL
941
+
942
+ common_data: dict[str, Any] | None = None
943
+ if common_path.is_file():
944
+ try:
945
+ data = read_json(common_path)
946
+ if isinstance(data, dict):
947
+ common_data = data
948
+ except (OSError, json.JSONDecodeError):
949
+ pass
950
+
951
+ exec_data: dict[str, Any] | None = None
952
+ if exec_path.is_file():
953
+ try:
954
+ data = read_json(exec_path)
955
+ if isinstance(data, dict):
956
+ exec_data = data
957
+ except (OSError, json.JSONDecodeError):
958
+ pass
959
+
960
+ if common_data is not None and common_data.get("schemaVersion") == 2:
961
+ common_data = project_profile_v3(common_data)
962
+ if exec_data is not None and exec_data.get("schemaVersion") == 2:
963
+ exec_data = project_profile_v3(exec_data)
964
+ if common_data is None and exec_data is None:
965
+ return None
966
+ if common_data is None:
967
+ return exec_data
968
+ if exec_data is None:
969
+ return common_data
970
+
971
+ # Merge: start from common, overlay execution's current commands.
972
+ merged = dict(common_data)
973
+ common_current = common_data.get("commands") or {}
974
+ exec_current = exec_data.get("commands") or {}
975
+ if common_current or exec_current:
976
+ merged_current = (
977
+ dict(common_current) if isinstance(common_current, dict) else {}
978
+ )
979
+ if isinstance(exec_current, dict):
980
+ merged_current.update(exec_current)
981
+ merged["commands"] = merged_current
982
+ # Legacy v1 aliases remain readable during migration.
983
+ common_cmds = common_data.get("buildCommands") or {}
984
+ exec_cmds = exec_data.get("buildCommands") or {}
985
+ if common_cmds or exec_cmds:
986
+ merged_cmds = dict(common_cmds) if isinstance(common_cmds, dict) else {}
987
+ if isinstance(exec_cmds, dict):
988
+ merged_cmds.update(exec_cmds)
989
+ merged["buildCommands"] = merged_cmds
990
+ if isinstance(merged.get("commands"), dict):
991
+ _derive_verification_inputs(merged)
992
+ for field in ("moduleGraph", "commandGraph", "migration"):
993
+ if field in exec_data:
994
+ merged[field] = exec_data[field]
995
+ exec_graph = exec_data.get("verificationGraph")
996
+ if isinstance(exec_graph, dict):
997
+ merged["verificationGraph"] = dict(exec_graph)
998
+ return merged
999
+
1000
+
1001
+ def _merge_user_overrides(
1002
+ profile: dict[str, Any], existing: dict[str, Any] | None
1003
+ ) -> None:
1004
+ """只保留 source=user 的 command 覆写;detected 字段每次 fresh 重建。
1005
+
1006
+ spec §3.1:user override 显式 source=user 才永久保留;detected basis 过期自动重探测。
1007
+ v1 profile 无 provenance 字段,buildCommands 不当 user override 保留(由 migrate 报告)。
1008
+ """
1009
+ if not existing:
1010
+ return
1011
+ cmds = existing.get("commands")
1012
+ if isinstance(cmds, dict):
1013
+ for key, cmd in cmds.items():
1014
+ if isinstance(cmd, dict) and cmd.get("source") == "user":
1015
+ profile["commands"][key] = dict(cmd)
1016
+ if isinstance(existing.get("knownPreexistingErrors"), list):
1017
+ profile["knownPreexistingErrors"] = list(existing["knownPreexistingErrors"])
1018
+ if isinstance(existing.get("shellQuirks"), list):
1019
+ profile["shellQuirks"] = list(existing["shellQuirks"])
1020
+ test_tracking = existing.get("testTracking")
1021
+ if isinstance(test_tracking, dict) and test_tracking.get("source") == "user":
1022
+ profile["testTracking"] = dict(test_tracking)
1023
+ graph = existing.get("verificationGraph")
1024
+ if isinstance(graph, dict) and graph.get("source") == "user":
1025
+ profile["verificationGraph"] = dict(graph)
1026
+ # verificationInputs 由 commands 重新派生。knownPreexistingErrors /
1027
+ # shellQuirks 是人工标注,保留。
1028
+
1029
+
1030
+ def _derive_verification_inputs(profile: dict[str, Any]) -> None:
1031
+ """从 commands 派生 verificationInputs.<key>,兼容 ledger v1 消费。"""
1032
+ cmds = profile.get("commands") or {}
1033
+ vi: dict[str, list[str]] = {}
1034
+ for key, cmd in cmds.items():
1035
+ if isinstance(cmd, dict) and isinstance(cmd.get("inputs"), list):
1036
+ vi[key] = list(cmd["inputs"])
1037
+ profile["verificationInputs"] = vi
1038
+
1039
+
1040
+ def _required_coverage(scope: Any) -> str:
1041
+ value = str(scope or "").strip()
1042
+ if value == "incremental":
1043
+ return "incremental"
1044
+ if value == "module-am":
1045
+ return "module-am"
1046
+ if value in {"module", "full"}:
1047
+ # A full command may satisfy the module-or-broader candidate gate.
1048
+ return "module"
1049
+ return "module"
1050
+
1051
+
1052
+ def _derive_verification_graph(profile: dict[str, Any]) -> None:
1053
+ existing = profile.get("verificationGraph")
1054
+ if isinstance(existing, dict) and existing.get("source") == "user":
1055
+ return
1056
+ commands = profile.get("commands")
1057
+ targets: dict[str, dict[str, Any]] = {}
1058
+ if isinstance(commands, dict):
1059
+ for key, command in sorted(commands.items()):
1060
+ if not isinstance(key, str) or not TARGET_NAME.fullmatch(key):
1061
+ continue
1062
+ if not isinstance(command, dict):
1063
+ continue
1064
+ depends_on = command.get("dependsOn")
1065
+ if not isinstance(depends_on, list):
1066
+ depends_on = []
1067
+ required_capabilities = command.get("requiredCapabilities")
1068
+ if not isinstance(required_capabilities, list):
1069
+ required_capabilities = []
1070
+ targets[key] = {
1071
+ "id": key,
1072
+ "commandKey": key,
1073
+ "argvTemplate": list(command.get("argvTemplate") or []),
1074
+ "inputs": list(command.get("inputs") or []),
1075
+ "coverage": command.get("coverage"),
1076
+ "coverageLevel": str(command.get("scope") or "module"),
1077
+ "dependsOn": [
1078
+ item
1079
+ for item in depends_on
1080
+ if isinstance(item, str) and TARGET_NAME.fullmatch(item)
1081
+ ],
1082
+ "requiredCoverage": _required_coverage(command.get("scope")),
1083
+ "candidate": key == "unitTestFull",
1084
+ "requiredCapabilities": [
1085
+ item
1086
+ for item in required_capabilities
1087
+ if isinstance(item, str) and item.strip()
1088
+ ],
1089
+ "resourceLocks": [],
1090
+ "estimatedDurationSeconds": 0,
1091
+ "requiresFrozenIdentity": key == "unitTestFull",
1092
+ "reusePolicy": "ledger-exact",
1093
+ }
1094
+ profile["verificationGraph"] = {
1095
+ "schemaVersion": 1,
1096
+ "source": "detected",
1097
+ "candidateTarget": (
1098
+ "unitTestFull" if "unitTestFull" in targets else None
1099
+ ),
1100
+ "targets": targets,
1101
+ }
1102
+
1103
+
1104
+ def manual_profile_template(
1105
+ excluded: tuple[str, ...] | list[str], components: list[str]
1106
+ ) -> dict[str, Any]:
1107
+ """歧义时交给调用方的可填骨架。
1108
+
1109
+ 骨架本身就是 `empty_profile_skeleton`——defaultsFingerprint、excludedRoots
1110
+ 默认集、commands/verificationInputs/verificationGraph 的键都已就位;这里只是
1111
+ 补上 commands 的条目形状与 requiredCoverage 取值,省掉调用方去源码里翻。
1112
+ 占位值用 `<...>` 包裹,便于替换后 grep 自检。
1113
+ """
1114
+ template = empty_profile_skeleton(excluded)
1115
+ template["projectType"] = (
1116
+ f"<从 detectedComponents 选定:{'|'.join(components)}>" if components else "<项目类型>"
1117
+ )
1118
+ template["commands"] = {
1119
+ "unitTest": {
1120
+ "command": "<在项目根可直接执行的增量测试命令>",
1121
+ "scope": "incremental",
1122
+ "inputs": ["<相对项目根的 glob,如 <component>/test/**>"],
1123
+ "coverage": "unitTest",
1124
+ "requiredCoverage": "incremental",
1125
+ "dependsOn": [],
1126
+ "source": "user",
1127
+ },
1128
+ "unitTestFull": {
1129
+ "command": "<在项目根可直接执行的全量测试命令>",
1130
+ "scope": "full",
1131
+ "inputs": ["<相对项目根的 glob,如 <component>/**>"],
1132
+ "coverage": "unitTestFull",
1133
+ "requiredCoverage": "module",
1134
+ "dependsOn": [],
1135
+ "source": "user",
1136
+ },
1137
+ }
1138
+ return template
1139
+
1140
+
1141
+ def detect(project: Path) -> dict[str, Any]:
1142
+ project = project.resolve()
1143
+ existing = load_profile(project)
1144
+ excluded = DEFAULT_EXCLUDED_ROOTS
1145
+ project_type = detect_project_type(project)
1146
+ components = discover_nested_components(project, excluded)
1147
+ component_types = {item.split(":", 1)[0] for item in components}
1148
+ if project_type != "unknown":
1149
+ component_types.add(project_type)
1150
+ if (project_type == "unknown" and components) or len(component_types) > 1:
1151
+ return {
1152
+ "ok": False,
1153
+ "code": "DETECTION_AMBIGUOUS",
1154
+ "action": "detect",
1155
+ "project": str(project),
1156
+ "profilePath": str(project / PROFILE_REL),
1157
+ "applied": False,
1158
+ "detectedComponents": components,
1159
+ "message": "multiple or nested project components require an explicit profile",
1160
+ # 拒绝生成可以,但不能连结构都不给:调用方否则只能反读本文件源码去凑
1161
+ # v3 形状。骨架不落盘——一份占位 profile 看起来"已配好"比没有更糟。
1162
+ "profileTemplate": manual_profile_template(excluded, components),
1163
+ "hint": (
1164
+ "手工声明一份 profile:以 profileTemplate 为骨架,按 detectedComponents "
1165
+ "选定产品包,commands 里的 inputs 用相对项目根的 glob(如 "
1166
+ f"'<component>/test/**'),填好后写入 {project / PROFILE_REL}"
1167
+ ),
1168
+ }
1169
+ if project_type == "unknown" and existing:
1170
+ return {
1171
+ "ok": False,
1172
+ "code": "DETECTION_INCONCLUSIVE",
1173
+ "action": "detect",
1174
+ "project": str(project),
1175
+ "profilePath": str(project / PROFILE_REL),
1176
+ "applied": False,
1177
+ "detectedComponents": [],
1178
+ "message": "detection was inconclusive; existing profile was preserved",
1179
+ }
1180
+
1181
+ profile = empty_profile_skeleton(excluded)
1182
+ profile["projectType"] = project_type
1183
+ profile["detectedAt"] = now_iso()
1184
+
1185
+ node_path = which_tool("node")
1186
+ mvn_path = which_tool("mvn")
1187
+ profile["toolPaths"] = {
1188
+ "node": node_path,
1189
+ "mvn": mvn_path,
1190
+ "gradle": which_tool("gradle"),
1191
+ "npm": which_tool("npm"),
1192
+ "pnpm": which_tool("pnpm"),
1193
+ }
1194
+
1195
+ pom_hash = sha256_file(project / "pom.xml") if (project / "pom.xml").is_file() else ""
1196
+ profile["fingerprint"] = {"mvnVersion": "", "nodeVersion": "", "pomHash": pom_hash}
1197
+
1198
+ if project_type == "java-maven":
1199
+ reactor_modules = find_reactor_modules(project, excluded)
1200
+ if reactor_modules:
1201
+ profile["commands"] = _java_commands(reactor_modules, pom_hash)
1202
+ profile["testTracking"]["paths"] = sorted(
1203
+ "src/test/**" if module == "." else f"{module}/src/test/**"
1204
+ for module in reactor_modules
1205
+ )
1206
+ elif project_type == "java-gradle":
1207
+ profile["commands"] = _gradle_commands(project)
1208
+ profile["testTracking"]["paths"] = ["src/test/**"]
1209
+ elif project_type == "node":
1210
+ node_cmds = _node_commands(project)
1211
+ if node_cmds:
1212
+ profile["commands"] = node_cmds
1213
+ profile["testTracking"]["paths"] = [
1214
+ pattern
1215
+ for extension in ("js", "jsx", "ts", "tsx", "mjs", "cjs")
1216
+ for pattern in (
1217
+ f"apps/*/test/**/*.{extension}",
1218
+ f"apps/*/tests/**/*.{extension}",
1219
+ f"packages/*/test/**/*.{extension}",
1220
+ f"packages/*/tests/**/*.{extension}",
1221
+ f"test/**/*.{extension}",
1222
+ f"tests/**/*.{extension}",
1223
+ f"**/*.test.{extension}",
1224
+ f"**/*.spec.{extension}",
1225
+ )
1226
+ ]
1227
+
1228
+ _merge_user_overrides(profile, existing)
1229
+ _derive_verification_inputs(profile)
1230
+ recommendation = recommend(project)
1231
+ profile["moduleGraph"] = recommendation["moduleGraph"]
1232
+ profile["commandGraph"] = {
1233
+ "targets": [
1234
+ {
1235
+ "id": key,
1236
+ "verification": key,
1237
+ "command": command.get("command"),
1238
+ "scope": command.get("scope"),
1239
+ "inputs": list(command.get("inputs") or []),
1240
+ "coverage": command.get("coverage"),
1241
+ "source": command.get("source"),
1242
+ }
1243
+ for key, command in sorted(profile["commands"].items())
1244
+ if isinstance(command, dict)
1245
+ ]
1246
+ + list(recommendation["targets"])
1247
+ }
1248
+ _derive_verification_graph(profile)
1249
+
1250
+ out_path = project / PROFILE_REL
1251
+ write_json(out_path, profile)
1252
+
1253
+ return {
1254
+ "ok": True,
1255
+ "action": "detect",
1256
+ "project": str(project),
1257
+ "profilePath": str(out_path),
1258
+ "profile": profile,
1259
+ "created": existing is None,
1260
+ "updated": True,
1261
+ "applied": True,
1262
+ }
1263
+
1264
+
1265
+ # ---------------------------------------------------------------------------
1266
+ # check(missing/invalid/stale/ready)
1267
+ # ---------------------------------------------------------------------------
1268
+
1269
+ def check(project: Path) -> dict[str, Any]:
1270
+ project = project.resolve()
1271
+ path = project / PROFILE_REL
1272
+ hint = "python harness_profile.py detect --project <root> --json"
1273
+
1274
+ if not path.is_file():
1275
+ return {
1276
+ "ok": False,
1277
+ "action": "check",
1278
+ "project": str(project),
1279
+ "status": "missing",
1280
+ "stale": True,
1281
+ "issues": ["build-profile.json missing; run detect"],
1282
+ "hint": hint,
1283
+ }
1284
+
1285
+ try:
1286
+ profile = read_json(path)
1287
+ except (OSError, json.JSONDecodeError) as exc:
1288
+ return {
1289
+ "ok": False,
1290
+ "action": "check",
1291
+ "project": str(project),
1292
+ "status": "invalid",
1293
+ "stale": True,
1294
+ "issues": [f"build-profile.json unreadable: {exc}"],
1295
+ "hint": hint,
1296
+ }
1297
+ if not isinstance(profile, dict):
1298
+ return {
1299
+ "ok": False,
1300
+ "action": "check",
1301
+ "project": str(project),
1302
+ "status": "invalid",
1303
+ "stale": True,
1304
+ "issues": ["build-profile.json is not an object"],
1305
+ "hint": hint,
1306
+ }
1307
+ if profile.get("schemaVersion") != SCHEMA_VERSION:
1308
+ return {
1309
+ "ok": False,
1310
+ "action": "check",
1311
+ "project": str(project),
1312
+ "status": "invalid",
1313
+ "stale": True,
1314
+ "issues": [
1315
+ f"schemaVersion={profile.get('schemaVersion')} != {SCHEMA_VERSION}; run migrate"
1316
+ ],
1317
+ "hint": "python harness_profile.py migrate --project <root> --apply --json",
1318
+ }
1319
+
1320
+ issues: list[str] = []
1321
+ stale = False
1322
+
1323
+ expected_defaults_fingerprint = profile_defaults_fingerprint()
1324
+ if profile.get("defaultsFingerprint") != expected_defaults_fingerprint:
1325
+ stale = True
1326
+ issues.append(
1327
+ "defaultsFingerprint is missing or outdated; run detect to refresh generated defaults"
1328
+ )
1329
+
1330
+ excluded_roots = {
1331
+ str(item).replace("\\", "/").strip("/")
1332
+ for item in (profile.get("excludedRoots") or [])
1333
+ if isinstance(item, str) and item.strip("/")
1334
+ }
1335
+ missing_excluded_roots = [
1336
+ item for item in DEFAULT_EXCLUDED_ROOTS if item not in excluded_roots
1337
+ ]
1338
+ if missing_excluded_roots:
1339
+ stale = True
1340
+ issues.append(
1341
+ "excludedRoots missing generated defaults: "
1342
+ + ", ".join(missing_excluded_roots)
1343
+ )
1344
+
1345
+ fp = profile.get("fingerprint") or {}
1346
+ stored_pom = fp.get("pomHash") or "" if isinstance(fp, dict) else ""
1347
+ current_pom = sha256_file(project / "pom.xml") if (project / "pom.xml").is_file() else ""
1348
+ if stored_pom != current_pom:
1349
+ stale = True
1350
+ issues.append(
1351
+ f"fingerprint.pomHash changed: stored={stored_pom or '(empty)'} "
1352
+ f"current={current_pom or '(empty)'}"
1353
+ )
1354
+
1355
+ # toolPaths 失效检查(空允许,路径不存在则 stale)
1356
+ tool_paths = profile.get("toolPaths") or {}
1357
+ if isinstance(tool_paths, dict):
1358
+ for tool_name in ("node", "mvn"):
1359
+ p = tool_paths.get(tool_name) or ""
1360
+ if p and not Path(p).exists():
1361
+ stale = True
1362
+ issues.append(f"toolPaths.{tool_name} missing: {p}")
1363
+
1364
+ structural_issues = validate_profile(profile, project)
1365
+ if structural_issues:
1366
+ stale = True
1367
+ issues.extend(structural_issues)
1368
+
1369
+ status = "stale" if stale else "ready"
1370
+ return {
1371
+ "ok": not stale,
1372
+ "action": "check",
1373
+ "project": str(project),
1374
+ "status": status,
1375
+ "stale": stale,
1376
+ "issues": issues,
1377
+ }
1378
+
1379
+
1380
+ # ---------------------------------------------------------------------------
1381
+ # validate_profile(路径逃逸 + excluded root)
1382
+ # ---------------------------------------------------------------------------
1383
+
1384
+ def validate_profile(profile: dict[str, Any], project: Path) -> list[str]:
1385
+ issues: list[str] = []
1386
+ excluded = tuple(profile.get("excludedRoots") or DEFAULT_EXCLUDED_ROOTS)
1387
+ commands = profile.get("commands") or {}
1388
+ for key, cmd in commands.items():
1389
+ if not isinstance(cmd, dict):
1390
+ continue
1391
+ for inp in cmd.get("inputs") or []:
1392
+ if not isinstance(inp, str) or not inp.strip():
1393
+ continue
1394
+ segments = inp.split("/")
1395
+ if ".." in segments:
1396
+ issues.append(
1397
+ f"command {key} input escapes project root: {inp}"
1398
+ )
1399
+ elif is_path_excluded(inp, excluded):
1400
+ issues.append(
1401
+ f"command {key} input in excluded root: {inp}"
1402
+ )
1403
+ graph = profile.get("verificationGraph")
1404
+ targets = graph.get("targets") if isinstance(graph, dict) else None
1405
+ if (
1406
+ not isinstance(graph, dict)
1407
+ or graph.get("schemaVersion") != 1
1408
+ or not isinstance(targets, dict)
1409
+ ):
1410
+ issues.append("verificationGraph schemaVersion=1 and targets object required")
1411
+ return issues
1412
+ dependencies: dict[str, list[str]] = {}
1413
+ for name, target in targets.items():
1414
+ if not isinstance(name, str) or not TARGET_NAME.fullmatch(name):
1415
+ issues.append(f"verification target name invalid: {name}")
1416
+ continue
1417
+ if not isinstance(target, dict):
1418
+ issues.append(f"verification target {name} must be an object")
1419
+ continue
1420
+ command_key = target.get("commandKey")
1421
+ if command_key not in commands:
1422
+ issues.append(
1423
+ f"verification target {name} references missing command {command_key}"
1424
+ )
1425
+ required = target.get("requiredCoverage")
1426
+ if required not in {"incremental", "module", "module-am", "full"}:
1427
+ issues.append(
1428
+ f"verification target {name} requiredCoverage invalid: {required}"
1429
+ )
1430
+ resource_locks = target.get("resourceLocks", [])
1431
+ if not isinstance(resource_locks, list) or any(
1432
+ not isinstance(item, str) or not item.strip()
1433
+ for item in resource_locks
1434
+ ):
1435
+ issues.append(
1436
+ f"verification target {name} resourceLocks must be non-empty strings"
1437
+ )
1438
+ estimated = target.get("estimatedDurationSeconds", 0)
1439
+ if (
1440
+ not isinstance(estimated, (int, float))
1441
+ or isinstance(estimated, bool)
1442
+ or estimated < 0
1443
+ ):
1444
+ issues.append(
1445
+ f"verification target {name} estimatedDurationSeconds invalid: {estimated}"
1446
+ )
1447
+ if target.get("reusePolicy", "ledger-exact") not in {
1448
+ "never",
1449
+ "identity-exact",
1450
+ "ledger-exact",
1451
+ }:
1452
+ issues.append(
1453
+ f"verification target {name} reusePolicy invalid"
1454
+ )
1455
+ raw_dependencies = target.get("dependsOn")
1456
+ if not isinstance(raw_dependencies, list):
1457
+ issues.append(f"verification target {name} dependsOn must be a list")
1458
+ continue
1459
+ dependencies[name] = []
1460
+ for dependency in raw_dependencies:
1461
+ if dependency not in targets:
1462
+ issues.append(
1463
+ f"verification target {name} depends on unknown target {dependency}"
1464
+ )
1465
+ elif isinstance(dependency, str):
1466
+ dependencies[name].append(dependency)
1467
+ candidate = graph.get("candidateTarget")
1468
+ if candidate is not None and candidate not in targets:
1469
+ issues.append(f"verificationGraph candidateTarget missing: {candidate}")
1470
+
1471
+ visiting: set[str] = set()
1472
+ visited: set[str] = set()
1473
+
1474
+ def visit(name: str) -> None:
1475
+ if name in visiting:
1476
+ issues.append(f"verification target dependency cycle at {name}")
1477
+ return
1478
+ if name in visited:
1479
+ return
1480
+ visiting.add(name)
1481
+ for dependency in dependencies.get(name, []):
1482
+ visit(dependency)
1483
+ visiting.remove(name)
1484
+ visited.add(name)
1485
+
1486
+ for target_name in dependencies:
1487
+ visit(target_name)
1488
+ return issues
1489
+
1490
+
1491
+ # ---------------------------------------------------------------------------
1492
+ # resolve_command(运行期占位替换,不写回持久 profile)
1493
+ # ---------------------------------------------------------------------------
1494
+
1495
+ def resolve_command(
1496
+ profile: dict[str, Any],
1497
+ key: str,
1498
+ *,
1499
+ test_classes: list[str] | None = None,
1500
+ modules: list[str] | None = None,
1501
+ common_root: Path | None = None,
1502
+ execution_root: Path | None = None,
1503
+ ) -> dict[str, Any]:
1504
+ cmd = (profile.get("commands") or {}).get(key)
1505
+ if not isinstance(cmd, dict):
1506
+ raise KeyError(f"command '{key}' not found in profile")
1507
+ replacements: dict[str, str] = {}
1508
+ if test_classes is not None:
1509
+ replacements["{testClasses}"] = ",".join(test_classes)
1510
+ if modules is not None:
1511
+ replacements["{modules}"] = ",".join(modules)
1512
+ if common_root is not None:
1513
+ replacements["{commonRoot}"] = str(common_root)
1514
+ if execution_root is not None:
1515
+ replacements["{executionRoot}"] = str(execution_root)
1516
+
1517
+ command = cmd.get("command", "")
1518
+ for placeholder, val in replacements.items():
1519
+ command = command.replace(placeholder, val)
1520
+
1521
+ argv: list[str] = []
1522
+ for tok in cmd.get("argvTemplate") or []:
1523
+ for placeholder, val in replacements.items():
1524
+ tok = tok.replace(placeholder, val)
1525
+ argv.append(tok)
1526
+
1527
+ return {
1528
+ "command": command,
1529
+ "argv": argv,
1530
+ "scope": cmd.get("scope"),
1531
+ "inputs": cmd.get("inputs"),
1532
+ "coverage": cmd.get("coverage"),
1533
+ "source": cmd.get("source"),
1534
+ }
1535
+
1536
+
1537
+ # ---------------------------------------------------------------------------
1538
+ # migrate(v1/v2 → v3,dry-run/apply/备份)
1539
+ # ---------------------------------------------------------------------------
1540
+
1541
+ def _backup_path(profile_path: Path, schema_version: int) -> Path:
1542
+ return profile_path.with_suffix(
1543
+ profile_path.suffix + f".v{schema_version}.bak"
1544
+ )
1545
+
1546
+
1547
+ def migrate(project: Path, *, dry_run: bool = True) -> dict[str, Any]:
1548
+ project = project.resolve()
1549
+ profile_path = project / PROFILE_REL
1550
+
1551
+ if not profile_path.is_file():
1552
+ return {
1553
+ "ok": True,
1554
+ "action": "migrate",
1555
+ "dry_run": dry_run,
1556
+ "needsMigration": False,
1557
+ "reason": "no profile to migrate",
1558
+ }
1559
+
1560
+ try:
1561
+ existing = read_json(profile_path)
1562
+ except (OSError, json.JSONDecodeError) as exc:
1563
+ return {
1564
+ "ok": False,
1565
+ "action": "migrate",
1566
+ "dry_run": dry_run,
1567
+ "needsMigration": False,
1568
+ "error": f"profile unreadable: {exc}",
1569
+ }
1570
+ if not isinstance(existing, dict):
1571
+ return {
1572
+ "ok": False,
1573
+ "action": "migrate",
1574
+ "dry_run": dry_run,
1575
+ "needsMigration": False,
1576
+ "error": "profile is not an object",
1577
+ }
1578
+
1579
+ sv = existing.get("schemaVersion")
1580
+ if sv == SCHEMA_VERSION:
1581
+ return {
1582
+ "ok": True,
1583
+ "action": "migrate",
1584
+ "dry_run": dry_run,
1585
+ "needsMigration": False,
1586
+ "reason": f"already schemaVersion {SCHEMA_VERSION}",
1587
+ }
1588
+ if sv not in {1, 2}:
1589
+ return {
1590
+ "ok": False,
1591
+ "action": "migrate",
1592
+ "dry_run": dry_run,
1593
+ "needsMigration": False,
1594
+ "error": f"unsupported schemaVersion: {sv}",
1595
+ }
1596
+
1597
+ # v1/v2 → v3 迁移清单
1598
+ changes = [
1599
+ f"schemaVersion {sv} → {SCHEMA_VERSION}",
1600
+ "新增 moduleGraph(模块边界/owned paths/dependency)",
1601
+ "新增 commandGraph(dynamic verification targets)",
1602
+ "source=user 命令字节语义保持不变并列入 protectedUserCommands",
1603
+ ]
1604
+ if sv == 1:
1605
+ changes.extend(
1606
+ [
1607
+ "buildCommands → commands(含 argvTemplate/scope/inputs/coverage/source/basis)",
1608
+ "excludedRoots 显式声明(.git/.harness/worktrees/target/build/node_modules/...)",
1609
+ "identifier 约束(pattern/maxLength/prefix)",
1610
+ ]
1611
+ )
1612
+ changes.append("commands → verificationGraph.targets 动态验证图")
1613
+ dropped: list[str] = []
1614
+ v1_svc = existing.get("serviceStart") or {}
1615
+ if isinstance(v1_svc, dict):
1616
+ # 具体 worktree/change 路径必须清除(不属于持久 profile 模板)
1617
+ for field in ("profile", "overlayPath"):
1618
+ val = v1_svc.get(field, "")
1619
+ if isinstance(val, str) and val.strip():
1620
+ dropped.append(f"serviceStart.{field}={val!r}(具体路径,已清除)")
1621
+ # 用户配置的服务命令/healthUrl 无 provenance,记录待确认(COM-007)
1622
+ for field in ("command", "healthUrl"):
1623
+ val = v1_svc.get(field, "")
1624
+ if isinstance(val, str) and val.strip():
1625
+ dropped.append(f"serviceStart.{field}(无 provenance,已备份;请用 record-quirk 重配)")
1626
+
1627
+ projected: dict[str, Any] | None = None
1628
+ if sv == 2:
1629
+ try:
1630
+ projected = project_profile_v3(existing)
1631
+ except ValueError as exc:
1632
+ code = str(exc).split(":", 1)[0]
1633
+ return {
1634
+ "ok": False,
1635
+ "code": (
1636
+ code
1637
+ if code in {"PROFILE_INVALID", "PROFILE_MIGRATION_UNSAFE"}
1638
+ else "PROFILE_MIGRATION_UNSAFE"
1639
+ ),
1640
+ "action": "migrate",
1641
+ "dry_run": dry_run,
1642
+ "needsMigration": True,
1643
+ "error": str(exc),
1644
+ "profilePath": str(profile_path),
1645
+ }
1646
+
1647
+ if dry_run:
1648
+ return {
1649
+ "ok": True,
1650
+ "code": "PROFILE_MIGRATION_PLANNED",
1651
+ "action": "migrate",
1652
+ "dry_run": True,
1653
+ "needsMigration": True,
1654
+ "changes": changes,
1655
+ "droppedFields": dropped,
1656
+ "profilePath": str(profile_path),
1657
+ "projectedProfile": projected,
1658
+ "protectedUserCommands": (
1659
+ projected.get("migration", {}).get("protectedUserCommands", [])
1660
+ if isinstance(projected, dict)
1661
+ else []
1662
+ ),
1663
+ }
1664
+
1665
+ # apply:先备份旧 profile。v2 结构兼容投影,不重新 detect,避免覆盖 user command。
1666
+ backup = _backup_path(profile_path, int(sv))
1667
+ if not backup.is_file():
1668
+ backup.write_text(profile_path.read_text(encoding="utf-8-sig"), encoding="utf-8", newline="\n")
1669
+ if sv == 2:
1670
+ assert projected is not None
1671
+ projected.setdefault("migration", {})["appliedAt"] = now_iso()
1672
+ write_json(profile_path, projected)
1673
+ else:
1674
+ detect(project)
1675
+ return {
1676
+ "ok": True,
1677
+ "code": "PROFILE_MIGRATED",
1678
+ "action": "migrate",
1679
+ "dry_run": False,
1680
+ "needsMigration": False,
1681
+ "changes": changes,
1682
+ "droppedFields": dropped,
1683
+ "backupPath": str(backup),
1684
+ "profilePath": str(profile_path),
1685
+ }
1686
+
1687
+
1688
+ # ---------------------------------------------------------------------------
1689
+ # CLI
1690
+ # ---------------------------------------------------------------------------
1691
+
1692
+ def build_parser() -> argparse.ArgumentParser:
1693
+ parser = argparse.ArgumentParser(
1694
+ prog="harness_profile.py",
1695
+ description="Harness Build Profile v3: recommend/detect/audit/validate/resolve/migrate",
1696
+ )
1697
+ parser.add_argument("--json", action="store_true", help="emit JSON")
1698
+ sub = parser.add_subparsers(dest="command", required=True)
1699
+
1700
+ p_detect = sub.add_parser("detect", help="probe and write build-profile.json")
1701
+ p_detect.add_argument("--project", required=True, type=Path)
1702
+ p_detect.add_argument("--json", action="store_true")
1703
+
1704
+ p_recommend = sub.add_parser(
1705
+ "recommend", help="analyze modules/targets without writing canonical profile"
1706
+ )
1707
+ p_recommend.add_argument("--project", required=True, type=Path)
1708
+ p_recommend.add_argument("--json", action="store_true")
1709
+
1710
+ p_audit = sub.add_parser(
1711
+ "audit", help="fail closed when module or command boundaries are unproven"
1712
+ )
1713
+ p_audit.add_argument("--project", required=True, type=Path)
1714
+ p_audit.add_argument("--json", action="store_true")
1715
+
1716
+ p_check = sub.add_parser("check", help="missing/invalid/stale/ready")
1717
+ p_check.add_argument("--project", required=True, type=Path)
1718
+ p_check.add_argument("--json", action="store_true")
1719
+
1720
+ p_validate = sub.add_parser("validate", help="validate profile containment")
1721
+ p_validate.add_argument("--project", required=True, type=Path)
1722
+ p_validate.add_argument("--json", action="store_true")
1723
+
1724
+ p_resolve = sub.add_parser(
1725
+ "resolve", help="resolve command template by key (runtime placeholder substitution)"
1726
+ )
1727
+ p_resolve.add_argument("--project", required=True, type=Path)
1728
+ p_resolve.add_argument("--key", required=True, help="command key in profile.commands")
1729
+ p_resolve.add_argument(
1730
+ "--test-classes",
1731
+ default=None,
1732
+ help="comma-separated test classes substituting {testClasses}",
1733
+ )
1734
+ p_resolve.add_argument(
1735
+ "--modules",
1736
+ default=None,
1737
+ help="comma-separated modules substituting {modules}",
1738
+ )
1739
+ p_resolve.add_argument("--json", action="store_true")
1740
+
1741
+ p_migrate = sub.add_parser("migrate", help="migrate v1/v2 → v3")
1742
+ p_migrate.add_argument("--project", required=True, type=Path)
1743
+ p_migrate.add_argument("--apply", action="store_true", help="apply (default: dry-run)")
1744
+ p_migrate.add_argument("--json", action="store_true")
1745
+
1746
+ return parser
1747
+
1748
+
1749
+ def main(argv: list[str] | None = None) -> int:
1750
+ parser = build_parser()
1751
+ args = parser.parse_args(argv)
1752
+ as_json = bool(getattr(args, "json", False))
1753
+
1754
+ if args.command == "detect":
1755
+ result = detect(args.project)
1756
+ sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
1757
+ return 0 if result.get("ok") else 1
1758
+ if args.command == "recommend":
1759
+ result = recommend(args.project)
1760
+ sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
1761
+ return 0 if result.get("ok") else 1
1762
+ if args.command == "audit":
1763
+ result = audit_profile(args.project)
1764
+ sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
1765
+ return 0 if result.get("ok") else 1
1766
+ if args.command == "check":
1767
+ result = check(args.project)
1768
+ sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
1769
+ return 0 if result.get("ok") and not result.get("stale") else 1
1770
+ if args.command == "validate":
1771
+ profile = load_profile(args.project.resolve())
1772
+ if profile is None:
1773
+ payload = {"ok": False, "issues": ["profile missing"]}
1774
+ else:
1775
+ issues = validate_profile(profile, args.project.resolve())
1776
+ payload = {"ok": not issues, "issues": issues}
1777
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
1778
+ return 0 if payload["ok"] else 1
1779
+ if args.command == "resolve":
1780
+ project = args.project.resolve()
1781
+ profile = load_profile(project)
1782
+ if profile is None:
1783
+ sys.stdout.write(
1784
+ json.dumps({"ok": False, "error": "profile missing"}, ensure_ascii=False) + "\n"
1785
+ )
1786
+ return 1
1787
+ test_classes = (
1788
+ [item for item in args.test_classes.split(",") if item]
1789
+ if args.test_classes
1790
+ else None
1791
+ )
1792
+ modules = (
1793
+ [item for item in args.modules.split(",") if item] if args.modules else None
1794
+ )
1795
+ try:
1796
+ resolved = resolve_command(
1797
+ profile, args.key, test_classes=test_classes, modules=modules
1798
+ )
1799
+ except KeyError as exc:
1800
+ sys.stdout.write(
1801
+ json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False) + "\n"
1802
+ )
1803
+ return 1
1804
+ payload = {"ok": True, "key": args.key, **resolved}
1805
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
1806
+ return 0
1807
+ if args.command == "migrate":
1808
+ result = migrate(args.project, dry_run=not args.apply)
1809
+ sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
1810
+ return 0 if result.get("ok") else 1
1811
+
1812
+ parser.error(f"unknown command: {args.command}")
1813
+ return 2
1814
+
1815
+
1816
+ if __name__ == "__main__":
1817
+ raise SystemExit(main())