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