@hunter-harness/workflow-harness 0.2.9 → 0.2.10
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-archive/reference.md +12 -2
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-review/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/reference.md +2 -0
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/claude-code/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/claude-code/harness-sync/reference.md +2 -2
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/reference.md +2 -0
- package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +803 -274
- package/harness/bundles/general/claude-code/scripts/harness_change.py +18 -0
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/claude-code/scripts/harness_events.py +104 -6
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +25 -4
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +440 -10
- package/harness/bundles/general/claude-code/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/claude-code/scripts/harness_review.py +326 -0
- package/harness/bundles/general/claude-code/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +244 -49
- 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-archive/reference.md +12 -2
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-review/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/reference.md +2 -0
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +2 -2
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -0
- package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +803 -274
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +18 -0
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +104 -6
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +25 -4
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +440 -10
- package/harness/bundles/general/codebuddy/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/codebuddy/scripts/harness_review.py +326 -0
- package/harness/bundles/general/codebuddy/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +244 -49
- 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-archive/reference.md +12 -2
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/reference.md +2 -0
- package/harness/bundles/general/codex/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/codex/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/codex/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/codex/harness-sync/reference.md +2 -2
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/reference.md +2 -0
- package/harness/bundles/general/codex/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/general/codex/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/general/codex/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +803 -274
- package/harness/bundles/general/codex/scripts/harness_change.py +18 -0
- package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/codex/scripts/harness_events.py +104 -6
- package/harness/bundles/general/codex/scripts/harness_gate.py +25 -4
- package/harness/bundles/general/codex/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/codex/scripts/harness_ledger.py +440 -10
- package/harness/bundles/general/codex/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/codex/scripts/harness_review.py +326 -0
- package/harness/bundles/general/codex/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +244 -49
- 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-archive/reference.md +12 -2
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/reference.md +2 -0
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/cursor/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/cursor/harness-sync/reference.md +2 -2
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/reference.md +2 -0
- package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/general/cursor/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +803 -274
- package/harness/bundles/general/cursor/scripts/harness_change.py +18 -0
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/cursor/scripts/harness_events.py +104 -6
- package/harness/bundles/general/cursor/scripts/harness_gate.py +25 -4
- package/harness/bundles/general/cursor/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +440 -10
- package/harness/bundles/general/cursor/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/cursor/scripts/harness_review.py +326 -0
- package/harness/bundles/general/cursor/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +244 -49
- 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-archive/reference.md +12 -2
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-review/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +15 -10
- package/harness/bundles/java/claude-code/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/claude-code/harness-sync/reference.md +2 -2
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +803 -274
- package/harness/bundles/java/claude-code/scripts/harness_change.py +18 -0
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/claude-code/scripts/harness_events.py +104 -6
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +25 -4
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +440 -10
- package/harness/bundles/java/claude-code/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/claude-code/scripts/harness_review.py +326 -0
- package/harness/bundles/java/claude-code/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +244 -49
- 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-archive/reference.md +12 -2
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-review/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +15 -10
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +2 -2
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +803 -274
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +18 -0
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +104 -6
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +25 -4
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +440 -10
- package/harness/bundles/java/codebuddy/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/codebuddy/scripts/harness_review.py +326 -0
- package/harness/bundles/java/codebuddy/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +244 -49
- 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-archive/reference.md +12 -2
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-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 +15 -10
- package/harness/bundles/java/codex/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/codex/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/codex/harness-sync/reference.md +2 -2
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/java/codex/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/java/codex/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +803 -274
- package/harness/bundles/java/codex/scripts/harness_change.py +18 -0
- package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/codex/scripts/harness_events.py +104 -6
- package/harness/bundles/java/codex/scripts/harness_gate.py +25 -4
- package/harness/bundles/java/codex/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/codex/scripts/harness_ledger.py +440 -10
- package/harness/bundles/java/codex/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/codex/scripts/harness_review.py +326 -0
- package/harness/bundles/java/codex/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +244 -49
- 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-archive/reference.md +12 -2
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +117 -0
- 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-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 +15 -10
- package/harness/bundles/java/cursor/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/cursor/harness-sync/reference.md +2 -2
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/java/cursor/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +803 -274
- package/harness/bundles/java/cursor/scripts/harness_change.py +18 -0
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/cursor/scripts/harness_events.py +104 -6
- package/harness/bundles/java/cursor/scripts/harness_gate.py +25 -4
- package/harness/bundles/java/cursor/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +440 -10
- package/harness/bundles/java/cursor/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/cursor/scripts/harness_review.py +326 -0
- package/harness/bundles/java/cursor/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +244 -49
- package/harness/manifests/general/claude-code.json +47 -31
- package/harness/manifests/general/codebuddy.json +47 -31
- package/harness/manifests/general/codex.json +47 -31
- package/harness/manifests/general/cursor.json +47 -31
- package/harness/manifests/java/claude-code.json +47 -31
- package/harness/manifests/java/codebuddy.json +47 -31
- package/harness/manifests/java/codex.json +47 -31
- package/harness/manifests/java/cursor.json +47 -31
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -54,7 +54,7 @@ MANIFEST_COMPARE_EXCLUDE = frozenset(
|
|
|
54
54
|
}
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
-
SCHEMA_VERSION = "2.
|
|
57
|
+
SCHEMA_VERSION = "2.3"
|
|
58
58
|
NOT_AVAILABLE = "not_available"
|
|
59
59
|
|
|
60
60
|
# Compiled once for evidence-text count fallbacks in _ledger_unit_tests / _ledger_api_tests.
|
|
@@ -68,6 +68,9 @@ if str(SCRIPTS_DIR) not in sys.path:
|
|
|
68
68
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
69
69
|
|
|
70
70
|
import harness_events as he # noqa: E402
|
|
71
|
+
import harness_ledger as hl # noqa: E402
|
|
72
|
+
import harness_paths as hp # noqa: E402
|
|
73
|
+
import harness_review as hr # noqa: E402
|
|
71
74
|
|
|
72
75
|
|
|
73
76
|
# ---------------------------------------------------------------------------
|
|
@@ -679,12 +682,19 @@ def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
679
682
|
|
|
680
683
|
metrics = unit.get("metrics")
|
|
681
684
|
if isinstance(metrics, dict) and any(
|
|
682
|
-
k in metrics for k in ("run", "testsRun", "failures", "errors", "skipped")
|
|
685
|
+
k in metrics for k in ("run", "testsRun", "total", "failures", "errors", "skipped")
|
|
683
686
|
):
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
687
|
+
if "total" in metrics:
|
|
688
|
+
# ledger v3 typed metrics (UT-005/RET-15): total/passed/failed.
|
|
689
|
+
run = int(metrics.get("total", 0) or 0)
|
|
690
|
+
failures = int(metrics.get("failed", 0) or 0)
|
|
691
|
+
errors = int(metrics.get("errors", 0) or 0)
|
|
692
|
+
skipped = int(metrics.get("skipped", 0) or 0)
|
|
693
|
+
else:
|
|
694
|
+
run = int(metrics.get("run", metrics.get("testsRun", 0)) or 0)
|
|
695
|
+
failures = int(metrics.get("failures", 0) or 0)
|
|
696
|
+
errors = int(metrics.get("errors", 0) or 0)
|
|
697
|
+
skipped = int(metrics.get("skipped", 0) or 0)
|
|
688
698
|
pass_rate = metrics.get("passRate")
|
|
689
699
|
source = "committed"
|
|
690
700
|
counted = run > 0 or failures > 0 or errors > 0 or skipped > 0
|
|
@@ -855,6 +865,53 @@ def _ledger_api_tests(
|
|
|
855
865
|
}
|
|
856
866
|
|
|
857
867
|
|
|
868
|
+
def _risks_from_test_results(change_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
869
|
+
"""PARTIAL/OPEN/UNKNOWN/DEFERRED scenarios -> (knownRisks, manualActions).
|
|
870
|
+
|
|
871
|
+
Scenario IDs are preserved so risks stay traceable to the test report
|
|
872
|
+
(UT-013/RET-25); PARTIAL is a fact, not a pass.
|
|
873
|
+
"""
|
|
874
|
+
risk_statuses = {"PARTIAL", "OPEN", "UNKNOWN", "DEFERRED", "BLOCKED"}
|
|
875
|
+
results_path = change_dir / "runtime" / "api-test-results.json"
|
|
876
|
+
if not results_path.is_file():
|
|
877
|
+
return [], []
|
|
878
|
+
try:
|
|
879
|
+
raw = read_json(results_path)
|
|
880
|
+
except (OSError, json.JSONDecodeError):
|
|
881
|
+
return [], []
|
|
882
|
+
scenarios = raw.get("scenarios") if isinstance(raw, dict) else None
|
|
883
|
+
if not isinstance(scenarios, list):
|
|
884
|
+
return [], []
|
|
885
|
+
risks: list[dict[str, Any]] = []
|
|
886
|
+
actions: list[dict[str, Any]] = []
|
|
887
|
+
for item in scenarios:
|
|
888
|
+
if not isinstance(item, dict):
|
|
889
|
+
continue
|
|
890
|
+
status = str(item.get("status") or "").upper()
|
|
891
|
+
if status not in risk_statuses:
|
|
892
|
+
continue
|
|
893
|
+
scenario_id = str(item.get("id") or item.get("scenario") or "").strip()
|
|
894
|
+
note = str(item.get("note") or item.get("message") or "").strip()
|
|
895
|
+
risks.append(
|
|
896
|
+
{
|
|
897
|
+
"phase": "test",
|
|
898
|
+
"severity": "medium",
|
|
899
|
+
"scenarioId": scenario_id,
|
|
900
|
+
"status": status,
|
|
901
|
+
"message": f"{scenario_id}: {status}" + (f" — {note}" if note else ""),
|
|
902
|
+
}
|
|
903
|
+
)
|
|
904
|
+
actions.append(
|
|
905
|
+
{
|
|
906
|
+
"stage": "test",
|
|
907
|
+
"status": status,
|
|
908
|
+
"scenarioId": scenario_id,
|
|
909
|
+
"action": "补齐该场景的完整验证或显式接受风险",
|
|
910
|
+
}
|
|
911
|
+
)
|
|
912
|
+
return risks, actions
|
|
913
|
+
|
|
914
|
+
|
|
858
915
|
def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
|
|
859
916
|
if not ledger:
|
|
860
917
|
return "NOT_RUN"
|
|
@@ -870,6 +927,58 @@ def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
|
|
|
870
927
|
return "NOT_RUN"
|
|
871
928
|
|
|
872
929
|
|
|
930
|
+
def _typed_test_metrics(entry: dict[str, Any], *, total_key: str) -> dict[str, Any]:
|
|
931
|
+
"""Project a ledger v3 typed metrics entry to the canonical view."""
|
|
932
|
+
metrics = entry.get("metrics") if isinstance(entry.get("metrics"), dict) else {}
|
|
933
|
+
total = int(metrics.get(total_key, 0) or 0)
|
|
934
|
+
passed = int(metrics.get("passed", 0) or 0)
|
|
935
|
+
failed = int(metrics.get("failed", 0) or 0)
|
|
936
|
+
status = str(entry.get("status") or "").upper() or "NOT_RUN"
|
|
937
|
+
out: dict[str, Any] = {
|
|
938
|
+
"status": status,
|
|
939
|
+
"total": total,
|
|
940
|
+
"passed": passed,
|
|
941
|
+
"failed": failed,
|
|
942
|
+
"passRate": f"{passed / total:.0%}" if total > 0 else NOT_AVAILABLE,
|
|
943
|
+
"source": "committed" if total > 0 else "not-run",
|
|
944
|
+
}
|
|
945
|
+
if "blocked" in metrics:
|
|
946
|
+
out["blocked"] = int(metrics.get("blocked", 0) or 0)
|
|
947
|
+
if "skipped" in metrics:
|
|
948
|
+
out["skipped"] = int(metrics.get("skipped", 0) or 0)
|
|
949
|
+
applicability = entry.get("applicability")
|
|
950
|
+
if isinstance(applicability, dict):
|
|
951
|
+
out["applicability"] = applicability
|
|
952
|
+
return out
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def build_verification_projection(
|
|
956
|
+
ledger: dict[str, Any] | None,
|
|
957
|
+
*,
|
|
958
|
+
change_dir: Path | None = None,
|
|
959
|
+
) -> dict[str, Any]:
|
|
960
|
+
"""Canonical verification view shared by collector and validators (RET-16).
|
|
961
|
+
|
|
962
|
+
apiContract and browserE2E are distinct typed projections; the legacy
|
|
963
|
+
apiTests mapping is retained for schema compatibility.
|
|
964
|
+
"""
|
|
965
|
+
validations = (ledger or {}).get("validations") or {}
|
|
966
|
+
projection: dict[str, Any] = {
|
|
967
|
+
"unitTests": _ledger_unit_tests(ledger),
|
|
968
|
+
"apiTests": _ledger_api_tests(ledger, change_dir=change_dir),
|
|
969
|
+
"dbCompatibility": _ledger_db_compat(ledger),
|
|
970
|
+
}
|
|
971
|
+
api_contract = validations.get("apiContract")
|
|
972
|
+
if isinstance(api_contract, dict):
|
|
973
|
+
projection["apiContract"] = _typed_test_metrics(
|
|
974
|
+
api_contract, total_key="scenariosTotal"
|
|
975
|
+
)
|
|
976
|
+
browser_e2e = validations.get("browserE2E")
|
|
977
|
+
if isinstance(browser_e2e, dict):
|
|
978
|
+
projection["browserE2E"] = _typed_test_metrics(browser_e2e, total_key="total")
|
|
979
|
+
return projection
|
|
980
|
+
|
|
981
|
+
|
|
873
982
|
def _parse_durations_from_log(log_text: str) -> dict[str, Any]:
|
|
874
983
|
"""Best-effort parse of harness-* sections from execution-log."""
|
|
875
984
|
stages: list[dict[str, Any]] = []
|
|
@@ -1044,7 +1153,15 @@ def _artifacts_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]
|
|
|
1044
1153
|
return out
|
|
1045
1154
|
|
|
1046
1155
|
|
|
1047
|
-
def _durations_from_event_phases(
|
|
1156
|
+
def _durations_from_event_phases(
|
|
1157
|
+
event_summary: dict[str, Any],
|
|
1158
|
+
events: list[dict[str, Any]] | None = None,
|
|
1159
|
+
) -> dict[str, Any]:
|
|
1160
|
+
# Canonical per-phase timing (UT-008/RET-20): one reducer feeds every view.
|
|
1161
|
+
canonical: dict[str, dict[str, Any]] = {}
|
|
1162
|
+
if events:
|
|
1163
|
+
for phase_name, phase_events in he.group_events_by_phase(events):
|
|
1164
|
+
canonical[phase_name] = he.canonical_phase_timing(phase_events)
|
|
1048
1165
|
stages: list[dict[str, Any]] = []
|
|
1049
1166
|
total_ms = 0
|
|
1050
1167
|
for name, info in (event_summary.get("phases") or {}).items():
|
|
@@ -1058,7 +1175,7 @@ def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any
|
|
|
1058
1175
|
result = "OK"
|
|
1059
1176
|
elif result in {"FAILED", "ERROR"}:
|
|
1060
1177
|
result = "FAIL"
|
|
1061
|
-
|
|
1178
|
+
stage: dict[str, Any] = {
|
|
1062
1179
|
"stage": str(name),
|
|
1063
1180
|
"skill": f"harness-{name}",
|
|
1064
1181
|
"startedAt": info.get("started_at") or NOT_AVAILABLE,
|
|
@@ -1066,7 +1183,13 @@ def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any
|
|
|
1066
1183
|
"minutes": minutes,
|
|
1067
1184
|
"result": result,
|
|
1068
1185
|
"attempts": attempts,
|
|
1069
|
-
}
|
|
1186
|
+
}
|
|
1187
|
+
timing = canonical.get(name)
|
|
1188
|
+
if timing:
|
|
1189
|
+
stage["activeExecutionMs"] = timing.get("activeExecutionMs")
|
|
1190
|
+
stage["wallClockSpanMs"] = timing.get("wallClockSpanMs")
|
|
1191
|
+
stage["lateEventCount"] = timing.get("lateEventCount")
|
|
1192
|
+
stages.append(stage)
|
|
1070
1193
|
total_min = round(total_ms / 60000, 2)
|
|
1071
1194
|
return {
|
|
1072
1195
|
"totalLabel": f"约 {int(round(total_min))} 分",
|
|
@@ -1316,94 +1439,6 @@ def write_archive_meta(work_dir: Path, summary: dict[str, Any]) -> Path:
|
|
|
1316
1439
|
return out
|
|
1317
1440
|
|
|
1318
1441
|
|
|
1319
|
-
def _patch_archive_stage(
|
|
1320
|
-
summary_path: Path,
|
|
1321
|
-
work_dir: Path,
|
|
1322
|
-
*,
|
|
1323
|
-
render: bool = True,
|
|
1324
|
-
) -> None:
|
|
1325
|
-
"""Backfill archive stage duration/status from events (optionally re-render).
|
|
1326
|
-
|
|
1327
|
-
When called from finalize step 9, pass ``render=False`` so the caller can
|
|
1328
|
-
write archive-meta + remanifest and invoke ``render_final_summary`` once.
|
|
1329
|
-
"""
|
|
1330
|
-
events_file = he.events_path(work_dir)
|
|
1331
|
-
events = he.load_events(events_file) if events_file.is_file() else []
|
|
1332
|
-
event_summary = he.build_summary(work_dir, events)
|
|
1333
|
-
archive_info = (event_summary.get("phases") or {}).get("archive") or {}
|
|
1334
|
-
duration_ms = archive_info.get("duration_ms")
|
|
1335
|
-
status = str(archive_info.get("status") or "OK").upper()
|
|
1336
|
-
if status in {"PASS", "PASSED", "SUCCESS"}:
|
|
1337
|
-
status = "OK"
|
|
1338
|
-
elif status in {"FAILED", "ERROR"}:
|
|
1339
|
-
status = "FAIL"
|
|
1340
|
-
|
|
1341
|
-
summary = read_json(summary_path)
|
|
1342
|
-
stage_status = summary.setdefault("stageStatus", {})
|
|
1343
|
-
if isinstance(stage_status, dict):
|
|
1344
|
-
stage_status["archive"] = status
|
|
1345
|
-
|
|
1346
|
-
durations = summary.setdefault("durations", {})
|
|
1347
|
-
stages = list(durations.get("stages") or [])
|
|
1348
|
-
minutes = round((duration_ms or 0) / 60000, 2) if duration_ms is not None else 0
|
|
1349
|
-
archive_stage = {
|
|
1350
|
-
"stage": "archive",
|
|
1351
|
-
"skill": "harness-archive",
|
|
1352
|
-
"startedAt": archive_info.get("started_at") or NOT_AVAILABLE,
|
|
1353
|
-
"endedAt": archive_info.get("ended_at") or NOT_AVAILABLE,
|
|
1354
|
-
"minutes": minutes,
|
|
1355
|
-
"result": status,
|
|
1356
|
-
"attempts": archive_info.get("attempts") if isinstance(archive_info.get("attempts"), list) else [],
|
|
1357
|
-
}
|
|
1358
|
-
replaced = False
|
|
1359
|
-
for idx, stage in enumerate(stages):
|
|
1360
|
-
if str(stage.get("stage") or "").lower() == "archive":
|
|
1361
|
-
stages[idx] = archive_stage
|
|
1362
|
-
replaced = True
|
|
1363
|
-
break
|
|
1364
|
-
if not replaced:
|
|
1365
|
-
stages.append(archive_stage)
|
|
1366
|
-
durations["stages"] = stages
|
|
1367
|
-
total_min = round(sum(float(s.get("minutes") or 0) for s in stages), 2)
|
|
1368
|
-
durations["totalMinutes"] = total_min
|
|
1369
|
-
durations["totalLabel"] = f"约 {int(round(total_min))} 分"
|
|
1370
|
-
|
|
1371
|
-
timeline = list(summary.get("timeline") or [])
|
|
1372
|
-
for item in timeline:
|
|
1373
|
-
if item.get("phase") == "archive" and item.get("type") not in {"decision", "issue"}:
|
|
1374
|
-
item["durationMs"] = duration_ms
|
|
1375
|
-
item["status"] = status
|
|
1376
|
-
item["endedAt"] = archive_info.get("ended_at")
|
|
1377
|
-
item["startedAt"] = archive_info.get("started_at")
|
|
1378
|
-
# Ensure at least one archive timeline entry with duration
|
|
1379
|
-
if duration_ms is not None and not any(
|
|
1380
|
-
t.get("phase") == "archive" and t.get("durationMs") is not None for t in timeline
|
|
1381
|
-
):
|
|
1382
|
-
timeline.append(
|
|
1383
|
-
{
|
|
1384
|
-
"phase": "archive",
|
|
1385
|
-
"attempt": 1,
|
|
1386
|
-
"startedAt": archive_info.get("started_at"),
|
|
1387
|
-
"endedAt": archive_info.get("ended_at"),
|
|
1388
|
-
"durationMs": duration_ms,
|
|
1389
|
-
"status": status,
|
|
1390
|
-
}
|
|
1391
|
-
)
|
|
1392
|
-
summary["timeline"] = timeline
|
|
1393
|
-
|
|
1394
|
-
skill_calls = list(summary.get("skillCalls") or [])
|
|
1395
|
-
found_skill = False
|
|
1396
|
-
for call in skill_calls:
|
|
1397
|
-
if str(call.get("skill") or "") in {"harness-archive", "archive"}:
|
|
1398
|
-
call["result"] = status
|
|
1399
|
-
found_skill = True
|
|
1400
|
-
if not found_skill:
|
|
1401
|
-
skill_calls.append({"skill": "harness-archive", "count": 1, "result": status})
|
|
1402
|
-
summary["skillCalls"] = skill_calls
|
|
1403
|
-
|
|
1404
|
-
write_json(summary_path, summary)
|
|
1405
|
-
if render:
|
|
1406
|
-
render_final_summary(work_dir, summary_path)
|
|
1407
1442
|
|
|
1408
1443
|
|
|
1409
1444
|
def _changed_files_from_git(
|
|
@@ -1471,6 +1506,37 @@ def _review_summary(
|
|
|
1471
1506
|
"yellowDeferred": 0,
|
|
1472
1507
|
"summary": "",
|
|
1473
1508
|
}
|
|
1509
|
+
sidecar = hr.findings_path(change_dir)
|
|
1510
|
+
if sidecar.is_file():
|
|
1511
|
+
status = hr.status(change_dir)
|
|
1512
|
+
items = status.get("items") or []
|
|
1513
|
+
red_items = [item for item in items if item.get("severity") == "RED"]
|
|
1514
|
+
yellow_items = [item for item in items if item.get("severity") == "YELLOW"]
|
|
1515
|
+
base.update(
|
|
1516
|
+
{
|
|
1517
|
+
"status": "ADVISORY",
|
|
1518
|
+
"red": len(red_items),
|
|
1519
|
+
"yellow": len(yellow_items),
|
|
1520
|
+
"redFixed": sum(
|
|
1521
|
+
1 for item in red_items if item.get("disposition") == "FIXED"
|
|
1522
|
+
),
|
|
1523
|
+
"redConfirmed": sum(
|
|
1524
|
+
1
|
|
1525
|
+
for item in red_items
|
|
1526
|
+
if item.get("disposition") in {"OPEN", "ACCEPTED_RISK", "DEFERRED"}
|
|
1527
|
+
),
|
|
1528
|
+
"yellowFixed": sum(
|
|
1529
|
+
1 for item in yellow_items if item.get("disposition") == "FIXED"
|
|
1530
|
+
),
|
|
1531
|
+
"yellowDeferred": sum(
|
|
1532
|
+
1
|
|
1533
|
+
for item in yellow_items
|
|
1534
|
+
if item.get("disposition") in {"DEFERRED", "ACCEPTED_RISK"}
|
|
1535
|
+
),
|
|
1536
|
+
"summary": f"structured review run {status.get('runId') or 'unknown'}",
|
|
1537
|
+
}
|
|
1538
|
+
)
|
|
1539
|
+
return base
|
|
1474
1540
|
if existing and isinstance(existing.get("reviewSummary"), dict):
|
|
1475
1541
|
merged = dict(base)
|
|
1476
1542
|
merged.update(existing["reviewSummary"])
|
|
@@ -1528,6 +1594,24 @@ def _business_goal_from_sources(change_dir: Path, events: list[dict[str, Any]])
|
|
|
1528
1594
|
scope = re.search(r"(?im)^\s*>?\s*(?:变更范围|目标)\s*[::]\s*(.+)$", body)
|
|
1529
1595
|
if scope:
|
|
1530
1596
|
return scope.group(1).strip()
|
|
1597
|
+
# UT-015/RET-27: structured "## 目标" section body wins over the first
|
|
1598
|
+
# task-table row — the task row is an activity, not the objective.
|
|
1599
|
+
section = re.search(
|
|
1600
|
+
r"(?im)^#{1,4}\s*(?:\d+[\.、]\s*)?(?:目标|业务目标|需求背景)\s*$",
|
|
1601
|
+
body,
|
|
1602
|
+
)
|
|
1603
|
+
if section:
|
|
1604
|
+
lines: list[str] = []
|
|
1605
|
+
for line in body[section.end():].splitlines():
|
|
1606
|
+
if re.match(r"^\s*#{1,4}\s", line):
|
|
1607
|
+
break
|
|
1608
|
+
clean = line.strip()
|
|
1609
|
+
if clean and not clean.startswith(("|", ">", "---")):
|
|
1610
|
+
lines.append(clean)
|
|
1611
|
+
if lines:
|
|
1612
|
+
break
|
|
1613
|
+
if lines:
|
|
1614
|
+
return lines[0]
|
|
1531
1615
|
first_task = re.search(r"(?m)^\s*\|\s*1\s*\|\s*([^|]+?)\s*\|", body)
|
|
1532
1616
|
if first_task:
|
|
1533
1617
|
return first_task.group(1).strip()
|
|
@@ -1677,9 +1761,10 @@ def collect_summary_data(
|
|
|
1677
1761
|
|
|
1678
1762
|
# verification
|
|
1679
1763
|
if not for_replay or not isinstance(data.get("verification"), dict):
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1764
|
+
projection = build_verification_projection(ledger, change_dir=change_dir)
|
|
1765
|
+
unit = projection["unitTests"]
|
|
1766
|
+
api = projection["apiTests"]
|
|
1767
|
+
db = projection["dbCompatibility"]
|
|
1683
1768
|
if not find_test_reports(change_dir) and unit.get("run", 0) == 0:
|
|
1684
1769
|
if "status" not in unit:
|
|
1685
1770
|
unit["status"] = "NOT_RUN"
|
|
@@ -1694,6 +1779,9 @@ def collect_summary_data(
|
|
|
1694
1779
|
"dbCompatibility": db,
|
|
1695
1780
|
"coverageDisplay": coverage,
|
|
1696
1781
|
}
|
|
1782
|
+
for typed_key in ("apiContract", "browserE2E"):
|
|
1783
|
+
if typed_key in projection:
|
|
1784
|
+
data["verification"][typed_key] = projection[typed_key]
|
|
1697
1785
|
else:
|
|
1698
1786
|
# Ensure nested keys exist for old schema 2.1
|
|
1699
1787
|
ver = data.setdefault("verification", {})
|
|
@@ -1720,7 +1808,7 @@ def collect_summary_data(
|
|
|
1720
1808
|
|
|
1721
1809
|
# durations / skillCalls
|
|
1722
1810
|
if events:
|
|
1723
|
-
data["durations"] = _durations_from_event_phases(event_summary)
|
|
1811
|
+
data["durations"] = _durations_from_event_phases(event_summary, events)
|
|
1724
1812
|
data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
|
|
1725
1813
|
elif log_text and (not for_replay or not data.get("durations")):
|
|
1726
1814
|
data["durations"] = _parse_durations_from_log(log_text)
|
|
@@ -1739,6 +1827,32 @@ def collect_summary_data(
|
|
|
1739
1827
|
head = data.get("finalCommit")
|
|
1740
1828
|
if base and head and base != NOT_AVAILABLE and head != NOT_AVAILABLE:
|
|
1741
1829
|
diff_stat, changed = _changed_files_from_git(project, str(base), str(head))
|
|
1830
|
+
ownership_projection: dict[str, Any] | None = None
|
|
1831
|
+
try:
|
|
1832
|
+
hp.load_change_contract(change_dir)
|
|
1833
|
+
ownership_projection = hl.compute_ownership_diff(
|
|
1834
|
+
project,
|
|
1835
|
+
base=str(base),
|
|
1836
|
+
head=str(head),
|
|
1837
|
+
change_dir=change_dir,
|
|
1838
|
+
)
|
|
1839
|
+
except (OSError, ValueError, RuntimeError):
|
|
1840
|
+
ownership_projection = None
|
|
1841
|
+
if ownership_projection is not None:
|
|
1842
|
+
allowed = set(ownership_projection.get("files") or [])
|
|
1843
|
+
changed = [item for item in changed if item.get("path") in allowed]
|
|
1844
|
+
diff_stat = {
|
|
1845
|
+
**diff_stat,
|
|
1846
|
+
"filesChanged": len(changed),
|
|
1847
|
+
"insertions": sum(int(item.get("insertions") or 0) for item in changed),
|
|
1848
|
+
"deletions": sum(int(item.get("deletions") or 0) for item in changed),
|
|
1849
|
+
}
|
|
1850
|
+
data["ownershipDiff"] = ownership_projection
|
|
1851
|
+
if write:
|
|
1852
|
+
write_json(
|
|
1853
|
+
change_dir / "evidence" / "ownership-diff.json",
|
|
1854
|
+
ownership_projection,
|
|
1855
|
+
)
|
|
1742
1856
|
if changed:
|
|
1743
1857
|
data["diffStat"] = diff_stat
|
|
1744
1858
|
data["changedFiles"] = changed
|
|
@@ -1765,6 +1879,17 @@ def collect_summary_data(
|
|
|
1765
1879
|
}
|
|
1766
1880
|
data["changedFiles"] = []
|
|
1767
1881
|
|
|
1882
|
+
data.setdefault(
|
|
1883
|
+
"ownershipDiff",
|
|
1884
|
+
{
|
|
1885
|
+
"files": [item.get("path") for item in data.get("changedFiles") or []],
|
|
1886
|
+
"staticEvidenceFiles": [],
|
|
1887
|
+
"foreignPaths": [],
|
|
1888
|
+
"excludedRuntimeCount": 0,
|
|
1889
|
+
"ownedFileCount": len(data.get("changedFiles") or []),
|
|
1890
|
+
},
|
|
1891
|
+
)
|
|
1892
|
+
|
|
1768
1893
|
# artifacts (build products stay empty unless already known; reportPipeline has event artifacts)
|
|
1769
1894
|
if not isinstance(data.get("artifacts"), list):
|
|
1770
1895
|
data["artifacts"] = []
|
|
@@ -1810,8 +1935,9 @@ def collect_summary_data(
|
|
|
1810
1935
|
if note:
|
|
1811
1936
|
maintenance_notes.append(note)
|
|
1812
1937
|
data["maintenanceNotes"] = maintenance_notes
|
|
1813
|
-
|
|
1814
|
-
data["
|
|
1938
|
+
scenario_risks, scenario_actions = _risks_from_test_results(change_dir)
|
|
1939
|
+
data["knownRisks"] = known_risks + scenario_risks
|
|
1940
|
+
data["manualActions"] = scenario_actions
|
|
1815
1941
|
for name, value in (data.get("stageStatus") or {}).items():
|
|
1816
1942
|
if value in {"BLOCKED", "BLOCKED_BY_ENV", "BLOCKED_BY_DBA", "NOT_RUN", "USER_SKIPPED"}:
|
|
1817
1943
|
data["manualActions"].append({
|
|
@@ -2085,6 +2211,288 @@ def render_final_summary(
|
|
|
2085
2211
|
# ---------------------------------------------------------------------------
|
|
2086
2212
|
|
|
2087
2213
|
|
|
2214
|
+
def _manifest_self_stats(
|
|
2215
|
+
work_dir: Path,
|
|
2216
|
+
manifest: dict[str, Any],
|
|
2217
|
+
manifest_path: Path,
|
|
2218
|
+
) -> dict[str, Any]:
|
|
2219
|
+
"""UT-011/RET-23: physical files vs manifest entries (self-exclusion)."""
|
|
2220
|
+
physical = sum(1 for path in work_dir.rglob("*") if path.is_file())
|
|
2221
|
+
entries = int(manifest.get("fileCount") or len(manifest.get("files") or []))
|
|
2222
|
+
rel_manifest = manifest_path.resolve().relative_to(work_dir.resolve()).as_posix()
|
|
2223
|
+
in_entries = any(
|
|
2224
|
+
str(item.get("path") or "").replace("\\", "/") == rel_manifest
|
|
2225
|
+
for item in manifest.get("files") or []
|
|
2226
|
+
if isinstance(item, dict)
|
|
2227
|
+
)
|
|
2228
|
+
coverage = round(entries / physical * 100, 2) if physical else 100.0
|
|
2229
|
+
return {
|
|
2230
|
+
"physicalFileCount": physical,
|
|
2231
|
+
"entryCount": entries,
|
|
2232
|
+
"selfExcluded": not in_entries,
|
|
2233
|
+
"coveragePercent": coverage,
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _append_finalize_failure_issue(
|
|
2238
|
+
original_change_dir: Path,
|
|
2239
|
+
code: str,
|
|
2240
|
+
message: str,
|
|
2241
|
+
) -> None:
|
|
2242
|
+
"""Record a finalize failure on the restored original as an issue event.
|
|
2243
|
+
|
|
2244
|
+
Never a second phase.end: the frozen cutoff already closed the archive
|
|
2245
|
+
phase exactly once (RET-19/RET-21).
|
|
2246
|
+
"""
|
|
2247
|
+
if not original_change_dir.is_dir():
|
|
2248
|
+
return
|
|
2249
|
+
try:
|
|
2250
|
+
append_event(
|
|
2251
|
+
original_change_dir,
|
|
2252
|
+
phase="archive",
|
|
2253
|
+
type_="issue",
|
|
2254
|
+
code=code,
|
|
2255
|
+
severity="error",
|
|
2256
|
+
message=message,
|
|
2257
|
+
)
|
|
2258
|
+
except OSError:
|
|
2259
|
+
pass
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
def _freeze_evidence_cutoff(work_dir: Path) -> dict[str, Any]:
|
|
2263
|
+
"""Freeze the events cutoff: fsync events, write evidence-cutoff.json.
|
|
2264
|
+
|
|
2265
|
+
After this point no event may be appended to the archived events file;
|
|
2266
|
+
the cutoff hash lets any later reader prove that (INT-006/RET-19).
|
|
2267
|
+
"""
|
|
2268
|
+
events_file = he.events_path(work_dir)
|
|
2269
|
+
events = he.load_events(events_file) if events_file.is_file() else []
|
|
2270
|
+
if events_file.is_file():
|
|
2271
|
+
# Windows fsync requires a writable handle; O_RDONLY raises EBADF.
|
|
2272
|
+
fd = os.open(str(events_file), os.O_RDWR | os.O_BINARY)
|
|
2273
|
+
try:
|
|
2274
|
+
os.fsync(fd)
|
|
2275
|
+
finally:
|
|
2276
|
+
os.close(fd)
|
|
2277
|
+
raw = events_file.read_bytes()
|
|
2278
|
+
else:
|
|
2279
|
+
raw = b""
|
|
2280
|
+
cutoff = {
|
|
2281
|
+
"eventCount": len(events),
|
|
2282
|
+
"sha256": "sha256:" + hashlib.sha256(raw).hexdigest(),
|
|
2283
|
+
"frozenAt": now_iso(),
|
|
2284
|
+
"path": "events.ndjson",
|
|
2285
|
+
}
|
|
2286
|
+
write_json(work_dir / "evidence" / "evidence-cutoff.json", cutoff)
|
|
2287
|
+
return cutoff
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
def validate_artifact_immutability(entries: list[dict[str, Any]]) -> dict[str, Any]:
|
|
2291
|
+
"""Same artifact path with two different hashes -> conflict (UT-003/RET-07)."""
|
|
2292
|
+
issues: list[dict[str, str]] = []
|
|
2293
|
+
seen: dict[str, str] = {}
|
|
2294
|
+
for item in entries:
|
|
2295
|
+
if not isinstance(item, dict):
|
|
2296
|
+
continue
|
|
2297
|
+
rel = str(item.get("path") or "").strip()
|
|
2298
|
+
digest = str(item.get("sha256") or "").strip()
|
|
2299
|
+
if not rel or not digest:
|
|
2300
|
+
continue
|
|
2301
|
+
prior = seen.get(rel)
|
|
2302
|
+
if prior is not None and prior != digest:
|
|
2303
|
+
issues.append(
|
|
2304
|
+
{
|
|
2305
|
+
"code": "artifact-hash-conflict",
|
|
2306
|
+
"severity": "error",
|
|
2307
|
+
"message": f"immutable artifact conflict at {rel}: {prior[:12]}… != {digest[:12]}…",
|
|
2308
|
+
}
|
|
2309
|
+
)
|
|
2310
|
+
else:
|
|
2311
|
+
seen[rel] = digest
|
|
2312
|
+
errors = [i for i in issues if i.get("severity") == "error"]
|
|
2313
|
+
return {
|
|
2314
|
+
"ok": len(errors) == 0,
|
|
2315
|
+
"issues": issues,
|
|
2316
|
+
"error_count": len(errors),
|
|
2317
|
+
"warning_count": len(issues) - len(errors),
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
|
|
2321
|
+
def validate_source_consistency(
|
|
2322
|
+
change_dir: Path,
|
|
2323
|
+
summary: dict[str, Any],
|
|
2324
|
+
) -> dict[str, Any]:
|
|
2325
|
+
"""Layer 1: summary facts must equal the frozen sources (UT-014/RET-26).
|
|
2326
|
+
|
|
2327
|
+
Checks event count against the cutoff file, verification counts against
|
|
2328
|
+
ledger typed metrics, and review counts against sidecars when present.
|
|
2329
|
+
"""
|
|
2330
|
+
issues: list[dict[str, str]] = []
|
|
2331
|
+
|
|
2332
|
+
# 1. event count/hash vs frozen cutoff (fallback: live events file).
|
|
2333
|
+
events_file = he.events_path(change_dir)
|
|
2334
|
+
actual_count = None
|
|
2335
|
+
cutoff_path = change_dir / "evidence" / "evidence-cutoff.json"
|
|
2336
|
+
cutoff: dict[str, Any] | None = None
|
|
2337
|
+
if cutoff_path.is_file():
|
|
2338
|
+
try:
|
|
2339
|
+
cutoff = read_json(cutoff_path)
|
|
2340
|
+
actual_count = cutoff.get("eventCount")
|
|
2341
|
+
except (OSError, json.JSONDecodeError):
|
|
2342
|
+
actual_count = None
|
|
2343
|
+
if actual_count is None and events_file.is_file():
|
|
2344
|
+
actual_count = len(he.load_events(events_file))
|
|
2345
|
+
summary_count = (summary.get("reportPipeline") or {}).get("event_count")
|
|
2346
|
+
if actual_count is not None and summary_count is not None:
|
|
2347
|
+
if int(summary_count) != int(actual_count):
|
|
2348
|
+
issues.append(
|
|
2349
|
+
{
|
|
2350
|
+
"code": "event-count-mismatch",
|
|
2351
|
+
"severity": "error",
|
|
2352
|
+
"message": (
|
|
2353
|
+
f"summary event_count={summary_count} but cutoff has "
|
|
2354
|
+
f"{actual_count} events"
|
|
2355
|
+
),
|
|
2356
|
+
}
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
if cutoff is not None and events_file.is_file():
|
|
2360
|
+
actual_hash = "sha256:" + hashlib.sha256(events_file.read_bytes()).hexdigest()
|
|
2361
|
+
if cutoff.get("sha256") != actual_hash:
|
|
2362
|
+
issues.append(
|
|
2363
|
+
{
|
|
2364
|
+
"code": "cutoff-hash-mismatch",
|
|
2365
|
+
"severity": "error",
|
|
2366
|
+
"message": "evidence cutoff hash does not match events.ndjson",
|
|
2367
|
+
}
|
|
2368
|
+
)
|
|
2369
|
+
|
|
2370
|
+
# 2. verification vs ledger typed metrics.
|
|
2371
|
+
ledger = load_ledger(change_dir)
|
|
2372
|
+
if ledger:
|
|
2373
|
+
projection = build_verification_projection(ledger, change_dir=change_dir)
|
|
2374
|
+
ver = summary.get("verification") or {}
|
|
2375
|
+
unit_src = projection.get("unitTests") or {}
|
|
2376
|
+
unit_sum = ver.get("unitTests") or {}
|
|
2377
|
+
if unit_src.get("run"):
|
|
2378
|
+
if int(unit_sum.get("run") or 0) != int(unit_src["run"]):
|
|
2379
|
+
issues.append(
|
|
2380
|
+
{
|
|
2381
|
+
"code": "verification-mismatch",
|
|
2382
|
+
"severity": "error",
|
|
2383
|
+
"message": (
|
|
2384
|
+
f"unitTests: summary run={unit_sum.get('run')} but "
|
|
2385
|
+
f"ledger projection run={unit_src['run']}"
|
|
2386
|
+
),
|
|
2387
|
+
}
|
|
2388
|
+
)
|
|
2389
|
+
for typed_key in ("apiContract", "browserE2E"):
|
|
2390
|
+
src = projection.get(typed_key)
|
|
2391
|
+
if not isinstance(src, dict) or not src.get("total"):
|
|
2392
|
+
continue
|
|
2393
|
+
rendered = ver.get(typed_key) or {}
|
|
2394
|
+
if int(rendered.get("total") or 0) != int(src["total"]):
|
|
2395
|
+
issues.append(
|
|
2396
|
+
{
|
|
2397
|
+
"code": "verification-mismatch",
|
|
2398
|
+
"severity": "error",
|
|
2399
|
+
"message": (
|
|
2400
|
+
f"{typed_key}: summary total={rendered.get('total')} "
|
|
2401
|
+
f"but ledger projection total={src['total']}"
|
|
2402
|
+
),
|
|
2403
|
+
}
|
|
2404
|
+
)
|
|
2405
|
+
|
|
2406
|
+
# 3. Rebuild canonical projections from frozen sources and compare the
|
|
2407
|
+
# fields that must never come from prose or an existing summary.
|
|
2408
|
+
expected = collect_summary_data(change_dir, write=False, for_replay=False)
|
|
2409
|
+
projection_fields = {
|
|
2410
|
+
"reviewSummary": "review-mismatch",
|
|
2411
|
+
"knownRisks": "risk-mismatch",
|
|
2412
|
+
"manualActions": "manual-actions-mismatch",
|
|
2413
|
+
"durations": "phase-timing-mismatch",
|
|
2414
|
+
"changedFiles": "ownership-diff-mismatch",
|
|
2415
|
+
"ownershipDiff": "ownership-diff-mismatch",
|
|
2416
|
+
"artifacts": "artifact-mismatch",
|
|
2417
|
+
}
|
|
2418
|
+
for field, code in projection_fields.items():
|
|
2419
|
+
if summary.get(field) != expected.get(field):
|
|
2420
|
+
issues.append(
|
|
2421
|
+
{
|
|
2422
|
+
"code": code,
|
|
2423
|
+
"severity": "error",
|
|
2424
|
+
"message": f"summary {field} does not match frozen source projection",
|
|
2425
|
+
}
|
|
2426
|
+
)
|
|
2427
|
+
|
|
2428
|
+
# 4. Manifest structure/checksums and summary semantics.
|
|
2429
|
+
before_path = change_dir / "evidence" / "archive-manifest-before.json"
|
|
2430
|
+
if before_path.is_file():
|
|
2431
|
+
try:
|
|
2432
|
+
manifest = read_json(before_path)
|
|
2433
|
+
entries = manifest.get("files") if isinstance(manifest, dict) else None
|
|
2434
|
+
valid = isinstance(entries, list) and manifest.get("fileCount") == len(entries)
|
|
2435
|
+
seen: set[str] = set()
|
|
2436
|
+
if valid:
|
|
2437
|
+
for entry in entries:
|
|
2438
|
+
rel = str(entry.get("path") or "") if isinstance(entry, dict) else ""
|
|
2439
|
+
digest = str(entry.get("sha256") or "") if isinstance(entry, dict) else ""
|
|
2440
|
+
if (
|
|
2441
|
+
not rel
|
|
2442
|
+
or rel in seen
|
|
2443
|
+
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
2444
|
+
):
|
|
2445
|
+
valid = False
|
|
2446
|
+
break
|
|
2447
|
+
seen.add(rel)
|
|
2448
|
+
target = (change_dir / rel).resolve()
|
|
2449
|
+
if not target.is_relative_to(change_dir.resolve()) or not target.is_file():
|
|
2450
|
+
valid = False
|
|
2451
|
+
break
|
|
2452
|
+
if not _manifest_path_excluded(rel) and sha256_file(target) != digest:
|
|
2453
|
+
valid = False
|
|
2454
|
+
break
|
|
2455
|
+
if not valid:
|
|
2456
|
+
issues.append(
|
|
2457
|
+
{
|
|
2458
|
+
"code": "manifest-invalid",
|
|
2459
|
+
"severity": "error",
|
|
2460
|
+
"message": "archive-manifest-before structure or checksum is invalid",
|
|
2461
|
+
}
|
|
2462
|
+
)
|
|
2463
|
+
except (OSError, json.JSONDecodeError):
|
|
2464
|
+
issues.append(
|
|
2465
|
+
{
|
|
2466
|
+
"code": "manifest-invalid",
|
|
2467
|
+
"severity": "error",
|
|
2468
|
+
"message": "archive-manifest-before is unreadable",
|
|
2469
|
+
}
|
|
2470
|
+
)
|
|
2471
|
+
|
|
2472
|
+
# 5. Artifact paths must resolve to immutable files inside the archive.
|
|
2473
|
+
root = change_dir.resolve()
|
|
2474
|
+
for artifact in summary.get("artifacts") or []:
|
|
2475
|
+
raw = str(artifact.get("path") or "") if isinstance(artifact, dict) else ""
|
|
2476
|
+
candidate = (root / raw).resolve() if raw else root
|
|
2477
|
+
if not raw or not candidate.is_relative_to(root) or not candidate.is_file():
|
|
2478
|
+
issues.append(
|
|
2479
|
+
{
|
|
2480
|
+
"code": "artifact-missing",
|
|
2481
|
+
"severity": "error",
|
|
2482
|
+
"message": f"artifact path is missing or outside archive: {raw}",
|
|
2483
|
+
}
|
|
2484
|
+
)
|
|
2485
|
+
|
|
2486
|
+
errors = [i for i in issues if i.get("severity") == "error"]
|
|
2487
|
+
warnings = [i for i in issues if i.get("severity") != "error"]
|
|
2488
|
+
return {
|
|
2489
|
+
"ok": len(errors) == 0,
|
|
2490
|
+
"issues": issues,
|
|
2491
|
+
"error_count": len(errors),
|
|
2492
|
+
"warning_count": len(warnings),
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
|
|
2088
2496
|
def validate_summary(
|
|
2089
2497
|
summary: dict[str, Any],
|
|
2090
2498
|
html_path: Path | None,
|
|
@@ -2199,7 +2607,12 @@ def validate_summary(
|
|
|
2199
2607
|
|
|
2200
2608
|
# risks / manual actions — empty arrays are OK (placeholder)
|
|
2201
2609
|
for risk in summary.get("knownRisks") or []:
|
|
2202
|
-
|
|
2610
|
+
# UT-016/RET-28: canonical projection — structured risk objects
|
|
2611
|
+
# project to their message; str(dict) never matches rendered HTML.
|
|
2612
|
+
if isinstance(risk, dict):
|
|
2613
|
+
text = str(risk.get("message") or risk.get("summary") or "").strip()
|
|
2614
|
+
else:
|
|
2615
|
+
text = str(risk)
|
|
2203
2616
|
if text and text not in html and _html_escape(text) not in html:
|
|
2204
2617
|
issues.append(
|
|
2205
2618
|
{
|
|
@@ -2420,6 +2833,43 @@ def cmd_finalize(
|
|
|
2420
2833
|
archive_root.mkdir(parents=True, exist_ok=True)
|
|
2421
2834
|
archive_dir = archive_root / f"{today_date()}-{change_name}"
|
|
2422
2835
|
project_root = find_project_root(original_change_dir)
|
|
2836
|
+
try:
|
|
2837
|
+
resolved_state_dir = hp.resolve_state_dir_for_contract(
|
|
2838
|
+
original_change_dir, project_root
|
|
2839
|
+
)
|
|
2840
|
+
except ValueError as exc:
|
|
2841
|
+
return 1, {
|
|
2842
|
+
"ok": False,
|
|
2843
|
+
"action": "finalize",
|
|
2844
|
+
"change_dir": str(original_change_dir),
|
|
2845
|
+
"error": f"invalid split runtime root: {exc}",
|
|
2846
|
+
}
|
|
2847
|
+
split_state_dir = (
|
|
2848
|
+
resolved_state_dir
|
|
2849
|
+
if resolved_state_dir.resolve() != original_change_dir.resolve()
|
|
2850
|
+
else None
|
|
2851
|
+
)
|
|
2852
|
+
split_materialized_names = (
|
|
2853
|
+
{item.name for item in split_state_dir.iterdir()}
|
|
2854
|
+
if split_state_dir is not None and split_state_dir.is_dir()
|
|
2855
|
+
else set()
|
|
2856
|
+
)
|
|
2857
|
+
|
|
2858
|
+
def _restore_finalize_failure() -> None:
|
|
2859
|
+
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
2860
|
+
if split_state_dir is None or not original_change_dir.is_dir():
|
|
2861
|
+
return
|
|
2862
|
+
# The split state remains authoritative on failure. Remove only the
|
|
2863
|
+
# top-level names materialized from that state so retry starts clean.
|
|
2864
|
+
for name in sorted(split_materialized_names):
|
|
2865
|
+
target = original_change_dir / name
|
|
2866
|
+
try:
|
|
2867
|
+
if target.is_dir():
|
|
2868
|
+
shutil.rmtree(target)
|
|
2869
|
+
elif target.exists():
|
|
2870
|
+
target.unlink()
|
|
2871
|
+
except OSError as exc:
|
|
2872
|
+
warnings.append(f"could not dematerialize split state {target}: {exc}")
|
|
2423
2873
|
|
|
2424
2874
|
payload: dict[str, Any] = {
|
|
2425
2875
|
"ok": False,
|
|
@@ -2454,7 +2904,7 @@ def cmd_finalize(
|
|
|
2454
2904
|
|
|
2455
2905
|
# --- 0. cleanup transients (before before-manifest) ---
|
|
2456
2906
|
try:
|
|
2457
|
-
cleanup_result = _cleanup_transients(work_dir)
|
|
2907
|
+
cleanup_result = _cleanup_transients(split_state_dir or work_dir)
|
|
2458
2908
|
payload["steps"]["cleanup"] = cleanup_result
|
|
2459
2909
|
deleted_n = len(cleanup_result.get("deleted") or [])
|
|
2460
2910
|
trunc_n = len(cleanup_result.get("truncated") or [])
|
|
@@ -2469,6 +2919,13 @@ def cmd_finalize(
|
|
|
2469
2919
|
warnings.append(f"cleanup failed: {exc}")
|
|
2470
2920
|
payload["steps"]["cleanup"] = {"ok": False, "error": str(exc)}
|
|
2471
2921
|
|
|
2922
|
+
if split_state_dir is not None and split_state_dir.is_dir():
|
|
2923
|
+
_merge_runtime_state(split_state_dir, work_dir)
|
|
2924
|
+
payload["steps"]["split_state_merge"] = {
|
|
2925
|
+
"ok": True,
|
|
2926
|
+
"stateDir": str(split_state_dir),
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2472
2929
|
# --- 1. before-manifest ---
|
|
2473
2930
|
before_path = work_dir / "evidence" / "archive-manifest-before.json"
|
|
2474
2931
|
try:
|
|
@@ -2502,6 +2959,10 @@ def cmd_finalize(
|
|
|
2502
2959
|
)
|
|
2503
2960
|
return 1, payload
|
|
2504
2961
|
|
|
2962
|
+
# Sync events/evidence appended after the first materialization.
|
|
2963
|
+
if split_state_dir is not None and split_state_dir.is_dir():
|
|
2964
|
+
_merge_runtime_state(split_state_dir, work_dir)
|
|
2965
|
+
|
|
2505
2966
|
# --- 2. move ---
|
|
2506
2967
|
try:
|
|
2507
2968
|
archive_root.mkdir(parents=True, exist_ok=True)
|
|
@@ -2533,7 +2994,33 @@ def cmd_finalize(
|
|
|
2533
2994
|
pass
|
|
2534
2995
|
return 1, payload
|
|
2535
2996
|
|
|
2536
|
-
# ---
|
|
2997
|
+
# --- 2b. user-flag decision (archive fact, must precede the cutoff) ---
|
|
2998
|
+
if allow_missing_review:
|
|
2999
|
+
_safe_append(
|
|
3000
|
+
phase="archive",
|
|
3001
|
+
type_="decision",
|
|
3002
|
+
note="review missing on full tier (allowed by user)",
|
|
3003
|
+
)
|
|
3004
|
+
|
|
3005
|
+
# --- 3. unique phase.end + freeze cutoff (RET-19 freeze-first) ---
|
|
3006
|
+
# From here on, NO event may be appended to the archived events file;
|
|
3007
|
+
# report generation runs as pure functions over the frozen sources.
|
|
3008
|
+
_safe_append(
|
|
3009
|
+
phase="archive",
|
|
3010
|
+
type_="phase.end",
|
|
3011
|
+
status="WARN" if warnings else "OK",
|
|
3012
|
+
note="finalize facts complete",
|
|
3013
|
+
)
|
|
3014
|
+
try:
|
|
3015
|
+
cutoff = _freeze_evidence_cutoff(work_dir)
|
|
3016
|
+
payload["steps"]["freeze"] = {"ok": True, "eventCount": cutoff["eventCount"]}
|
|
3017
|
+
except OSError as exc:
|
|
3018
|
+
payload["error"] = f"freeze failed: {exc}"
|
|
3019
|
+
payload["steps"]["freeze"] = {"ok": False, "error": str(exc)}
|
|
3020
|
+
_restore_finalize_failure()
|
|
3021
|
+
return 1, payload
|
|
3022
|
+
|
|
3023
|
+
# --- 4. collect (pure function of frozen sources) ---
|
|
2537
3024
|
try:
|
|
2538
3025
|
summary = collect_summary_data(
|
|
2539
3026
|
work_dir,
|
|
@@ -2549,51 +3036,42 @@ def cmd_finalize(
|
|
|
2549
3036
|
reasons.append(reason)
|
|
2550
3037
|
summary["finalStatusReasons"] = reasons
|
|
2551
3038
|
write_json(summary_path, summary)
|
|
2552
|
-
_safe_append(
|
|
2553
|
-
phase="archive",
|
|
2554
|
-
type_="decision",
|
|
2555
|
-
note=reason,
|
|
2556
|
-
)
|
|
2557
3039
|
payload["steps"]["collect"] = {"ok": True, "path": str(summary_path)}
|
|
2558
|
-
_safe_append(
|
|
2559
|
-
phase="archive",
|
|
2560
|
-
type_="artifact",
|
|
2561
|
-
path="reports/final/summary-data.json",
|
|
2562
|
-
kind="summary-data",
|
|
2563
|
-
)
|
|
2564
3040
|
except Exception as exc: # noqa: BLE001 — surface collect failures
|
|
2565
3041
|
payload["error"] = f"collect failed: {exc}"
|
|
2566
3042
|
payload["steps"]["collect"] = {"ok": False, "error": str(exc)}
|
|
2567
|
-
|
|
3043
|
+
_restore_finalize_failure()
|
|
3044
|
+
return 1, payload
|
|
3045
|
+
|
|
3046
|
+
# --- 5. source consistency (layer 1; UT-014) ---
|
|
3047
|
+
source_result = validate_source_consistency(work_dir, summary)
|
|
3048
|
+
payload["steps"]["source_consistency"] = source_result
|
|
3049
|
+
summary.setdefault("reportPipeline", {})["sourceConsistency"] = {
|
|
3050
|
+
"ok": bool(source_result.get("ok")),
|
|
3051
|
+
"issues": source_result.get("issues") or [],
|
|
3052
|
+
}
|
|
3053
|
+
try:
|
|
3054
|
+
write_json(summary_path, summary)
|
|
3055
|
+
except OSError as exc:
|
|
3056
|
+
warnings.append(f"could not write sourceConsistency: {exc}")
|
|
3057
|
+
if not source_result.get("ok"):
|
|
3058
|
+
payload["issues"] = source_result.get("issues") or []
|
|
3059
|
+
payload["error"] = "source consistency failed; original change dir restored"
|
|
3060
|
+
_restore_finalize_failure()
|
|
3061
|
+
_append_finalize_failure_issue(original_change_dir, "source-consistency-failed", payload["error"])
|
|
3062
|
+
payload["warnings"] = warnings
|
|
3063
|
+
payload["ok"] = False
|
|
2568
3064
|
return 1, payload
|
|
2569
3065
|
|
|
2570
|
-
# ---
|
|
3066
|
+
# --- 6. render (Node, else Python fallback) ---
|
|
2571
3067
|
render_result = render_final_summary(work_dir, summary_path)
|
|
2572
3068
|
payload["steps"]["render"] = render_result
|
|
2573
3069
|
if not render_result.get("ok"):
|
|
2574
|
-
# Node + fallback both failed: restore + exit non-0 (§4.1 rule 4)。
|
|
2575
3070
|
# 永不关闭一个没有 final-summary 的归档。
|
|
2576
3071
|
msg = str(render_result.get("fallbackReason") or "render failed")
|
|
2577
3072
|
payload["error"] = f"final-summary render failed: {msg}"
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
type_="issue",
|
|
2581
|
-
code="render-failed",
|
|
2582
|
-
severity="error",
|
|
2583
|
-
message=msg,
|
|
2584
|
-
)
|
|
2585
|
-
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
2586
|
-
_restore_target = original_change_dir if original_change_dir.is_dir() else work_dir
|
|
2587
|
-
try:
|
|
2588
|
-
append_event(
|
|
2589
|
-
_restore_target,
|
|
2590
|
-
phase="archive",
|
|
2591
|
-
type_="phase.end",
|
|
2592
|
-
status="FAIL",
|
|
2593
|
-
note="finalize aborted; render failed; original preserved",
|
|
2594
|
-
)
|
|
2595
|
-
except OSError:
|
|
2596
|
-
pass
|
|
3073
|
+
_restore_finalize_failure()
|
|
3074
|
+
_append_finalize_failure_issue(original_change_dir, "render-failed", msg)
|
|
2597
3075
|
payload["warnings"] = warnings
|
|
2598
3076
|
payload["ok"] = False
|
|
2599
3077
|
return 1, payload
|
|
@@ -2603,24 +3081,10 @@ def cmd_finalize(
|
|
|
2603
3081
|
f"node render unavailable; used python-fallback: "
|
|
2604
3082
|
f"{render_result.get('fallbackReason')}"
|
|
2605
3083
|
)
|
|
2606
|
-
_safe_append(
|
|
2607
|
-
phase="archive",
|
|
2608
|
-
type_="artifact",
|
|
2609
|
-
path="reports/final/final-summary.html",
|
|
2610
|
-
kind="final-report",
|
|
2611
|
-
)
|
|
2612
|
-
_safe_append(
|
|
2613
|
-
phase="archive",
|
|
2614
|
-
type_="command",
|
|
2615
|
-
command=f"render-final-summary ({renderer})",
|
|
2616
|
-
exit_code=0,
|
|
2617
|
-
note=f"final-summary rendered by {renderer}",
|
|
2618
|
-
)
|
|
2619
3084
|
|
|
2620
3085
|
html_path = work_dir / "reports" / "final" / "final-summary.html"
|
|
2621
3086
|
|
|
2622
|
-
# ---
|
|
2623
|
-
# Refresh summary from disk (collect already wrote it); do not re-collect.
|
|
3087
|
+
# --- 7. renderer consistency (layer 2) ---
|
|
2624
3088
|
try:
|
|
2625
3089
|
summary = read_json(summary_path)
|
|
2626
3090
|
except (OSError, json.JSONDecodeError):
|
|
@@ -2638,20 +3102,19 @@ def cmd_finalize(
|
|
|
2638
3102
|
except OSError as exc:
|
|
2639
3103
|
warnings.append(f"could not write validationIssues: {exc}")
|
|
2640
3104
|
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
)
|
|
3105
|
+
# --- 8. archive-meta (before after-manifest so the manifest covers it) ---
|
|
3106
|
+
try:
|
|
3107
|
+
summary = read_json(summary_path)
|
|
3108
|
+
meta_path = write_archive_meta(work_dir, summary)
|
|
3109
|
+
payload["steps"]["archive_meta"] = {"ok": True, "path": str(meta_path)}
|
|
3110
|
+
except Exception as exc: # noqa: BLE001 — meta soft-fail
|
|
3111
|
+
warnings.append(f"archive-meta write failed: {exc}")
|
|
3112
|
+
payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
|
|
2649
3113
|
|
|
2650
|
-
# ---
|
|
3114
|
+
# --- 9. after-manifest + compare ---
|
|
2651
3115
|
after_path = work_dir / "evidence" / "archive-manifest-after.json"
|
|
2652
3116
|
try:
|
|
2653
3117
|
after_manifest = generate_manifest(work_dir, after_path)
|
|
2654
|
-
# Re-read before from archive (moved with the tree)
|
|
2655
3118
|
before_in_archive = work_dir / "evidence" / "archive-manifest-before.json"
|
|
2656
3119
|
if before_in_archive.is_file():
|
|
2657
3120
|
before_manifest = read_json(before_in_archive)
|
|
@@ -2661,46 +3124,34 @@ def cmd_finalize(
|
|
|
2661
3124
|
"path": str(after_path),
|
|
2662
3125
|
"compare": compare_result,
|
|
2663
3126
|
}
|
|
2664
|
-
_safe_append(
|
|
2665
|
-
phase="archive",
|
|
2666
|
-
type_="artifact",
|
|
2667
|
-
path="evidence/archive-manifest-after.json",
|
|
2668
|
-
kind="manifest-after",
|
|
2669
|
-
)
|
|
2670
3127
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
2671
3128
|
payload["error"] = f"after-manifest failed: {exc}"
|
|
2672
3129
|
payload["steps"]["after_manifest"] = {"ok": False, "error": str(exc)}
|
|
2673
|
-
|
|
3130
|
+
_restore_finalize_failure()
|
|
2674
3131
|
return 1, payload
|
|
2675
3132
|
|
|
2676
|
-
#
|
|
3133
|
+
# --- 10. final summary update + final render/validate (no more events) ---
|
|
3134
|
+
summary = read_json(summary_path)
|
|
2677
3135
|
summary["archiveManifest"] = {
|
|
2678
3136
|
"movedFiles": compare_result.get("movedFiles", 0),
|
|
2679
3137
|
"generatedFiles": compare_result.get("generatedFiles", 0),
|
|
2680
3138
|
"totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
|
|
2681
3139
|
"checksumStatus": compare_result.get("checksumStatus", "FAIL"),
|
|
3140
|
+
**_manifest_self_stats(work_dir, after_manifest, after_path),
|
|
2682
3141
|
}
|
|
2683
|
-
|
|
2684
|
-
write_json(summary_path, summary)
|
|
2685
|
-
except OSError as exc:
|
|
2686
|
-
warnings.append(f"could not update archiveManifest: {exc}")
|
|
2687
|
-
|
|
2688
|
-
# The first render is needed so the after-manifest includes a final report,
|
|
2689
|
-
# but it predates the final manifest statistics. Render and validate once
|
|
2690
|
-
# more from the now-final summary so HTML never contradicts summary-data.
|
|
3142
|
+
write_json(summary_path, summary)
|
|
2691
3143
|
render_result = render_final_summary(work_dir, summary_path)
|
|
2692
3144
|
if not render_result.get("ok"):
|
|
2693
3145
|
payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
|
|
2694
|
-
|
|
3146
|
+
_restore_finalize_failure()
|
|
2695
3147
|
return 1, payload
|
|
3148
|
+
summary = read_json(summary_path)
|
|
2696
3149
|
validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
|
|
2697
3150
|
payload["steps"]["validate"] = validate_result
|
|
2698
3151
|
summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get("issues") or []
|
|
2699
3152
|
write_json(summary_path, summary)
|
|
2700
3153
|
|
|
2701
|
-
# ---
|
|
2702
|
-
# After move, "original" is gone; on failure we restore. On success, ensure
|
|
2703
|
-
# the changes path does not linger.
|
|
3154
|
+
# --- 11. closure: only when both validators and manifest pass ---
|
|
2704
3155
|
validate_ok = bool(validate_result.get("ok"))
|
|
2705
3156
|
manifest_ok = bool(compare_result.get("ok"))
|
|
2706
3157
|
can_close = validate_ok and manifest_ok
|
|
@@ -2721,19 +3172,10 @@ def cmd_finalize(
|
|
|
2721
3172
|
payload["issues"] = issues_out
|
|
2722
3173
|
payload["error"] = "validate or manifest check failed; original change dir restored"
|
|
2723
3174
|
payload["steps"]["delete_original"] = {"ok": False, "deleted": False}
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
append_event(
|
|
2729
|
-
_safe_append_restored,
|
|
2730
|
-
phase="archive",
|
|
2731
|
-
type_="phase.end",
|
|
2732
|
-
status="FAIL",
|
|
2733
|
-
note="finalize aborted; original preserved",
|
|
2734
|
-
)
|
|
2735
|
-
except OSError:
|
|
2736
|
-
pass
|
|
3175
|
+
_restore_finalize_failure()
|
|
3176
|
+
_append_finalize_failure_issue(
|
|
3177
|
+
original_change_dir, "closure-failed", payload["error"]
|
|
3178
|
+
)
|
|
2737
3179
|
payload["warnings"] = warnings
|
|
2738
3180
|
payload["ok"] = False
|
|
2739
3181
|
return 1, payload
|
|
@@ -2753,16 +3195,15 @@ def cmd_finalize(
|
|
|
2753
3195
|
"note": "removed by move",
|
|
2754
3196
|
}
|
|
2755
3197
|
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
3198
|
+
if split_state_dir is not None and split_state_dir.is_dir():
|
|
3199
|
+
try:
|
|
3200
|
+
shutil.rmtree(split_state_dir)
|
|
3201
|
+
payload["steps"]["delete_runtime_state"] = {"ok": True, "deleted": True}
|
|
3202
|
+
except OSError as exc:
|
|
3203
|
+
warnings.append(f"could not remove archived runtime state: {exc}")
|
|
3204
|
+
payload["steps"]["delete_runtime_state"] = {"ok": False, "error": str(exc)}
|
|
2763
3205
|
|
|
2764
|
-
# ---
|
|
2765
|
-
# knowledge subprocesses; it enqueues a pending outbox item and returns) ---
|
|
3206
|
+
# --- 12. maintenance outbox + service ---
|
|
2766
3207
|
if skip_ingest:
|
|
2767
3208
|
payload["steps"]["knowledge"] = {"skipped": True, "reason": "--skip-ingest"}
|
|
2768
3209
|
payload["knowledgeMaintenance"] = "SKIPPED"
|
|
@@ -2781,74 +3222,6 @@ def cmd_finalize(
|
|
|
2781
3222
|
if service_result.get("warning"):
|
|
2782
3223
|
warnings.append(str(service_result["warning"]))
|
|
2783
3224
|
|
|
2784
|
-
# --- 9a. archive-meta + artifact event BEFORE phase.end ---
|
|
2785
|
-
# phase.end must remain the last archive event; patch (9b) still runs after
|
|
2786
|
-
# phase.end so it can read duration/status from events.
|
|
2787
|
-
try:
|
|
2788
|
-
summary = read_json(summary_path)
|
|
2789
|
-
meta_path = write_archive_meta(work_dir, summary)
|
|
2790
|
-
payload["steps"]["archive_meta"] = {"ok": True, "path": str(meta_path)}
|
|
2791
|
-
_safe_append(
|
|
2792
|
-
phase="archive",
|
|
2793
|
-
type_="artifact",
|
|
2794
|
-
path="meta/archive-meta.md",
|
|
2795
|
-
kind="archive-meta",
|
|
2796
|
-
)
|
|
2797
|
-
except Exception as exc: # noqa: BLE001 — meta soft-fail
|
|
2798
|
-
warnings.append(f"archive-meta write failed: {exc}")
|
|
2799
|
-
payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
|
|
2800
|
-
|
|
2801
|
-
_safe_append(
|
|
2802
|
-
phase="archive",
|
|
2803
|
-
type_="phase.end",
|
|
2804
|
-
status="WARN" if warnings else "OK",
|
|
2805
|
-
note="finalize complete",
|
|
2806
|
-
)
|
|
2807
|
-
|
|
2808
|
-
# --- 9b. patch archive stage + remanifest + single render (no more events) ---
|
|
2809
|
-
try:
|
|
2810
|
-
_patch_archive_stage(summary_path, work_dir, render=False)
|
|
2811
|
-
payload["steps"]["patch_archive"] = {"ok": True}
|
|
2812
|
-
except Exception as exc: # noqa: BLE001 — patch must not roll back archive
|
|
2813
|
-
warnings.append(f"archive stage patch failed: {exc}")
|
|
2814
|
-
payload["steps"]["patch_archive"] = {"ok": False, "error": str(exc)}
|
|
2815
|
-
|
|
2816
|
-
try:
|
|
2817
|
-
summary = read_json(summary_path)
|
|
2818
|
-
# Refresh archive-meta with patched stageStatus/durations (no new event).
|
|
2819
|
-
meta_path = write_archive_meta(work_dir, summary)
|
|
2820
|
-
payload["steps"]["archive_meta"] = {"ok": True, "path": str(meta_path)}
|
|
2821
|
-
after_manifest = generate_manifest(work_dir, after_path)
|
|
2822
|
-
before_in_archive = work_dir / "evidence" / "archive-manifest-before.json"
|
|
2823
|
-
if before_in_archive.is_file():
|
|
2824
|
-
before_manifest = read_json(before_in_archive)
|
|
2825
|
-
if before_manifest is None:
|
|
2826
|
-
raise ValueError(
|
|
2827
|
-
"before-manifest unavailable for remanifest compare "
|
|
2828
|
-
f"(missing {before_in_archive.as_posix()})"
|
|
2829
|
-
)
|
|
2830
|
-
compare_result = compare_manifests(before_manifest, after_manifest)
|
|
2831
|
-
summary["archiveManifest"] = {
|
|
2832
|
-
"movedFiles": compare_result.get("movedFiles", 0),
|
|
2833
|
-
"generatedFiles": compare_result.get("generatedFiles", 0),
|
|
2834
|
-
"totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
|
|
2835
|
-
"checksumStatus": compare_result.get("checksumStatus", "FAIL"),
|
|
2836
|
-
}
|
|
2837
|
-
write_json(summary_path, summary)
|
|
2838
|
-
render_final_summary(work_dir, summary_path)
|
|
2839
|
-
payload["steps"]["after_manifest"] = {
|
|
2840
|
-
"ok": True,
|
|
2841
|
-
"path": str(after_path),
|
|
2842
|
-
"compare": compare_result,
|
|
2843
|
-
"includesArchiveMeta": True,
|
|
2844
|
-
}
|
|
2845
|
-
except Exception as exc: # noqa: BLE001 — meta/remanifest soft-fail
|
|
2846
|
-
warnings.append(f"archive-meta/remanifest failed: {exc}")
|
|
2847
|
-
payload["steps"]["archive_meta"] = {
|
|
2848
|
-
"ok": False,
|
|
2849
|
-
"error": str(exc),
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
3225
|
payload["ok"] = True
|
|
2853
3226
|
payload["warnings"] = warnings
|
|
2854
3227
|
payload["summary_data"] = str(summary_path)
|
|
@@ -2856,6 +3229,17 @@ def cmd_finalize(
|
|
|
2856
3229
|
return 0, payload
|
|
2857
3230
|
|
|
2858
3231
|
|
|
3232
|
+
def _merge_runtime_state(state_dir: Path, contract_dir: Path) -> None:
|
|
3233
|
+
"""Materialize split-v1 dynamic state into the archive contract tree."""
|
|
3234
|
+
for source in sorted(state_dir.iterdir()):
|
|
3235
|
+
target = contract_dir / source.name
|
|
3236
|
+
if source.is_dir():
|
|
3237
|
+
shutil.copytree(source, target, dirs_exist_ok=True, copy_function=shutil.copy2)
|
|
3238
|
+
elif source.is_file():
|
|
3239
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
3240
|
+
shutil.copy2(source, target)
|
|
3241
|
+
|
|
3242
|
+
|
|
2859
3243
|
def _restore_on_failure(
|
|
2860
3244
|
archive_dir: Path,
|
|
2861
3245
|
original_change_dir: Path,
|
|
@@ -2981,6 +3365,144 @@ def cmd_replay_cli(args: argparse.Namespace) -> int:
|
|
|
2981
3365
|
return code
|
|
2982
3366
|
|
|
2983
3367
|
|
|
3368
|
+
def _render_html_to(
|
|
3369
|
+
change_dir: Path,
|
|
3370
|
+
summary_path: Path,
|
|
3371
|
+
out_path: Path,
|
|
3372
|
+
) -> dict[str, Any]:
|
|
3373
|
+
"""Render summary HTML to an arbitrary path (node first, python fallback).
|
|
3374
|
+
|
|
3375
|
+
Unlike ``render_final_summary`` this never touches the canonical
|
|
3376
|
+
``reports/final/final-summary.html`` — repair renders into staging.
|
|
3377
|
+
"""
|
|
3378
|
+
project = find_project_root(change_dir)
|
|
3379
|
+
node = resolve_node_path(project)
|
|
3380
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3381
|
+
if node and RENDER_SCRIPT.is_file():
|
|
3382
|
+
try:
|
|
3383
|
+
proc = subprocess.run(
|
|
3384
|
+
[node, str(RENDER_SCRIPT), "--summary", str(summary_path), "--out", str(out_path)],
|
|
3385
|
+
capture_output=True,
|
|
3386
|
+
text=True,
|
|
3387
|
+
encoding="utf-8",
|
|
3388
|
+
errors="replace",
|
|
3389
|
+
timeout=60,
|
|
3390
|
+
check=False,
|
|
3391
|
+
)
|
|
3392
|
+
if proc.returncode == 0 and out_path.is_file():
|
|
3393
|
+
return {"ok": True, "renderer": "node", "out_path": str(out_path)}
|
|
3394
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
3395
|
+
pass
|
|
3396
|
+
try:
|
|
3397
|
+
summary = read_json(summary_path)
|
|
3398
|
+
html = render_fallback_html(summary)
|
|
3399
|
+
out_path.write_text(html, encoding="utf-8", newline="\n")
|
|
3400
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
3401
|
+
return {"ok": False, "renderer": "none", "error": str(exc)}
|
|
3402
|
+
return {"ok": True, "renderer": "python-fallback", "out_path": str(out_path)}
|
|
3403
|
+
|
|
3404
|
+
|
|
3405
|
+
def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
|
|
3406
|
+
"""Versioned repair (task 11 / RET-40).
|
|
3407
|
+
|
|
3408
|
+
Re-collect a candidate from the frozen sources, run both validators, and
|
|
3409
|
+
only then write an immutable ``derived/v<N>/`` plus a repair record. The
|
|
3410
|
+
original summary/HTML/manifest is never overwritten; the authoritative
|
|
3411
|
+
pointer moves only when both validators pass.
|
|
3412
|
+
"""
|
|
3413
|
+
payload: dict[str, Any] = {
|
|
3414
|
+
"ok": False,
|
|
3415
|
+
"action": "repair",
|
|
3416
|
+
"archive_dir": str(archive_dir),
|
|
3417
|
+
}
|
|
3418
|
+
if not archive_dir.is_dir():
|
|
3419
|
+
payload["error"] = f"archive dir not found: {archive_dir}"
|
|
3420
|
+
return 1, payload
|
|
3421
|
+
summary_path = archive_dir / "reports" / "final" / "summary-data.json"
|
|
3422
|
+
if not summary_path.is_file():
|
|
3423
|
+
payload["error"] = "summary-data.json missing; cannot repair"
|
|
3424
|
+
return 1, payload
|
|
3425
|
+
|
|
3426
|
+
# 1. candidate: fresh collect from frozen sources (read-only on archive).
|
|
3427
|
+
try:
|
|
3428
|
+
candidate = collect_summary_data(archive_dir, write=False, for_replay=False)
|
|
3429
|
+
except Exception as exc: # noqa: BLE001
|
|
3430
|
+
payload["error"] = f"repair collect failed: {exc}"
|
|
3431
|
+
return 1, payload
|
|
3432
|
+
|
|
3433
|
+
# 2. stage outside the archive; run both validators on the candidate.
|
|
3434
|
+
staging = Path(tempfile.mkdtemp(prefix="harness-repair-"))
|
|
3435
|
+
try:
|
|
3436
|
+
staged_summary = staging / "summary-data.json"
|
|
3437
|
+
write_json(staged_summary, candidate)
|
|
3438
|
+
source_result = validate_source_consistency(archive_dir, candidate)
|
|
3439
|
+
render_result = _render_html_to(
|
|
3440
|
+
archive_dir, staged_summary, staging / "final-summary.html"
|
|
3441
|
+
)
|
|
3442
|
+
staged_html = staging / "final-summary.html"
|
|
3443
|
+
renderer_result = validate_summary(
|
|
3444
|
+
candidate,
|
|
3445
|
+
staged_html if staged_html.is_file() else None,
|
|
3446
|
+
)
|
|
3447
|
+
payload["validators"] = {
|
|
3448
|
+
"source": source_result,
|
|
3449
|
+
"renderer": renderer_result,
|
|
3450
|
+
}
|
|
3451
|
+
if not (source_result.get("ok") and renderer_result.get("ok")):
|
|
3452
|
+
payload["error"] = "repair validators failed; derived version not written"
|
|
3453
|
+
return 1, payload
|
|
3454
|
+
|
|
3455
|
+
# 3. immutable derived version.
|
|
3456
|
+
derived = archive_dir / "derived"
|
|
3457
|
+
derived.mkdir(exist_ok=True)
|
|
3458
|
+
existing = [
|
|
3459
|
+
int(p.name[1:])
|
|
3460
|
+
for p in derived.iterdir()
|
|
3461
|
+
if p.is_dir() and p.name.startswith("v") and p.name[1:].isdigit()
|
|
3462
|
+
]
|
|
3463
|
+
version = f"v{(max(existing) + 1) if existing else 1}"
|
|
3464
|
+
version_dir = derived / version
|
|
3465
|
+
version_dir.mkdir()
|
|
3466
|
+
final_summary = version_dir / "summary-data.json"
|
|
3467
|
+
write_json(final_summary, candidate)
|
|
3468
|
+
if staged_html.is_file():
|
|
3469
|
+
shutil.copy2(staged_html, version_dir / "final-summary.html")
|
|
3470
|
+
record = {
|
|
3471
|
+
"version": version,
|
|
3472
|
+
"createdAt": now_iso(),
|
|
3473
|
+
"summarySha256": "sha256:" + hashlib.sha256(final_summary.read_bytes()).hexdigest(),
|
|
3474
|
+
"baseSummarySha256": "sha256:" + hashlib.sha256(summary_path.read_bytes()).hexdigest(),
|
|
3475
|
+
"validators": {
|
|
3476
|
+
"source": {"ok": bool(source_result.get("ok")), "issues": source_result.get("issues") or []},
|
|
3477
|
+
"renderer": {"ok": bool(renderer_result.get("ok")), "issues": renderer_result.get("issues") or []},
|
|
3478
|
+
},
|
|
3479
|
+
}
|
|
3480
|
+
write_json(version_dir / "repair-record.json", record)
|
|
3481
|
+
|
|
3482
|
+
# 4. authoritative pointer — only after both validators passed.
|
|
3483
|
+
write_json(
|
|
3484
|
+
derived / "authoritative.json",
|
|
3485
|
+
{
|
|
3486
|
+
"version": version,
|
|
3487
|
+
"summarySha256": record["summarySha256"],
|
|
3488
|
+
"updatedAt": record["createdAt"],
|
|
3489
|
+
},
|
|
3490
|
+
)
|
|
3491
|
+
payload["ok"] = True
|
|
3492
|
+
payload["version"] = version
|
|
3493
|
+
payload["derived_dir"] = str(version_dir)
|
|
3494
|
+
return 0, payload
|
|
3495
|
+
finally:
|
|
3496
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
3497
|
+
|
|
3498
|
+
|
|
3499
|
+
def cmd_repair_cli(args: argparse.Namespace) -> int:
|
|
3500
|
+
archive_dir = resolve_path(args.archive_dir)
|
|
3501
|
+
code, payload = cmd_repair(archive_dir)
|
|
3502
|
+
emit_json(payload)
|
|
3503
|
+
return code
|
|
3504
|
+
|
|
3505
|
+
|
|
2984
3506
|
def build_parser() -> argparse.ArgumentParser:
|
|
2985
3507
|
parser = argparse.ArgumentParser(
|
|
2986
3508
|
prog="harness_archive.py",
|
|
@@ -3016,6 +3538,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3016
3538
|
p_rep.add_argument("--json", action="store_true", default=True)
|
|
3017
3539
|
p_rep.set_defaults(func=cmd_replay_cli)
|
|
3018
3540
|
|
|
3541
|
+
p_repair = sub.add_parser(
|
|
3542
|
+
"repair", help="versioned repair: validated derived version, original untouched"
|
|
3543
|
+
)
|
|
3544
|
+
p_repair.add_argument("--archive-dir", required=True)
|
|
3545
|
+
p_repair.add_argument("--json", action="store_true", default=True)
|
|
3546
|
+
p_repair.set_defaults(func=cmd_repair_cli)
|
|
3547
|
+
|
|
3019
3548
|
return parser
|
|
3020
3549
|
|
|
3021
3550
|
|