@hunter-harness/workflow-harness 0.2.59 → 0.2.61
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/README.md +14 -16
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +43 -46
- package/harness/bundles/general/claude-code/harness-archive/checklist.md +10 -10
- package/harness/bundles/general/claude-code/harness-archive/reference.md +29 -22
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +1 -7
- package/harness/bundles/general/claude-code/harness-codebase-map/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 +4 -4
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +5 -3
- package/harness/bundles/general/claude-code/harness-run/checklist.md +10 -9
- package/harness/bundles/general/claude-code/harness-run/protocols.md +2 -0
- package/harness/bundles/general/claude-code/harness-run/reference.md +19 -16
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-test/checklist.md +2 -2
- package/harness/bundles/general/claude-code/harness-test/pitfalls.md +3 -3
- package/harness/bundles/general/claude-code/harness-test/reference.md +2 -2
- package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +18 -25
- package/harness/bundles/general/claude-code/protocols/evidence-based-reporting-protocol.md +5 -5
- package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +7 -7
- package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +10 -9
- package/harness/bundles/general/claude-code/protocols/sensitive-info-protocol.md +3 -3
- package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +5 -6
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +1043 -1108
- package/harness/bundles/general/claude-code/scripts/harness_context.py +8 -2
- package/harness/bundles/general/claude-code/scripts/harness_events.py +5 -0
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +79 -2
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +7 -0
- package/harness/bundles/general/claude-code/scripts/harness_state.py +22 -3
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/README.md +14 -16
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +43 -46
- package/harness/bundles/general/codebuddy/harness-archive/checklist.md +10 -10
- package/harness/bundles/general/codebuddy/harness-archive/reference.md +29 -22
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +1 -7
- package/harness/bundles/general/codebuddy/harness-codebase-map/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 +4 -4
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +5 -3
- package/harness/bundles/general/codebuddy/harness-run/checklist.md +10 -9
- package/harness/bundles/general/codebuddy/harness-run/protocols.md +2 -0
- package/harness/bundles/general/codebuddy/harness-run/reference.md +19 -16
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-test/checklist.md +2 -2
- package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +3 -3
- package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -2
- package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +18 -25
- package/harness/bundles/general/codebuddy/protocols/evidence-based-reporting-protocol.md +5 -5
- package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +7 -7
- package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +10 -9
- package/harness/bundles/general/codebuddy/protocols/sensitive-info-protocol.md +3 -3
- package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +5 -6
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +1043 -1108
- package/harness/bundles/general/codebuddy/scripts/harness_context.py +8 -2
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +5 -0
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +79 -2
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +7 -0
- package/harness/bundles/general/codebuddy/scripts/harness_state.py +22 -3
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/README.md +14 -16
- package/harness/bundles/general/codex/harness-archive/SKILL.md +43 -46
- package/harness/bundles/general/codex/harness-archive/checklist.md +10 -10
- package/harness/bundles/general/codex/harness-archive/reference.md +29 -22
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +1 -7
- package/harness/bundles/general/codex/harness-codebase-map/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 +4 -4
- package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-run/SKILL.md +5 -3
- package/harness/bundles/general/codex/harness-run/checklist.md +10 -9
- package/harness/bundles/general/codex/harness-run/protocols.md +2 -0
- package/harness/bundles/general/codex/harness-run/reference.md +19 -16
- package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-test/checklist.md +2 -2
- package/harness/bundles/general/codex/harness-test/pitfalls.md +3 -3
- package/harness/bundles/general/codex/harness-test/reference.md +2 -2
- package/harness/bundles/general/codex/protocols/archive-report-protocol.md +18 -25
- package/harness/bundles/general/codex/protocols/evidence-based-reporting-protocol.md +5 -5
- package/harness/bundles/general/codex/protocols/ledger-protocol.md +7 -7
- package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +10 -9
- package/harness/bundles/general/codex/protocols/sensitive-info-protocol.md +3 -3
- package/harness/bundles/general/codex/protocols/state-layout-protocol.md +5 -6
- package/harness/bundles/general/codex/scripts/harness_archive.py +1043 -1108
- package/harness/bundles/general/codex/scripts/harness_context.py +8 -2
- package/harness/bundles/general/codex/scripts/harness_events.py +5 -0
- package/harness/bundles/general/codex/scripts/harness_ledger.py +79 -2
- package/harness/bundles/general/codex/scripts/harness_profile.py +7 -0
- package/harness/bundles/general/codex/scripts/harness_state.py +22 -3
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/README.md +14 -16
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +43 -46
- package/harness/bundles/general/cursor/harness-archive/checklist.md +10 -10
- package/harness/bundles/general/cursor/harness-archive/reference.md +29 -22
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +1 -7
- package/harness/bundles/general/cursor/harness-codebase-map/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 +4 -4
- package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-run/SKILL.md +5 -3
- package/harness/bundles/general/cursor/harness-run/checklist.md +10 -9
- package/harness/bundles/general/cursor/harness-run/protocols.md +2 -0
- package/harness/bundles/general/cursor/harness-run/reference.md +19 -16
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-test/checklist.md +2 -2
- package/harness/bundles/general/cursor/harness-test/pitfalls.md +3 -3
- package/harness/bundles/general/cursor/harness-test/reference.md +2 -2
- package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +18 -25
- package/harness/bundles/general/cursor/protocols/evidence-based-reporting-protocol.md +5 -5
- package/harness/bundles/general/cursor/protocols/ledger-protocol.md +7 -7
- package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +10 -9
- package/harness/bundles/general/cursor/protocols/sensitive-info-protocol.md +3 -3
- package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +5 -6
- package/harness/bundles/general/cursor/scripts/harness_archive.py +1043 -1108
- package/harness/bundles/general/cursor/scripts/harness_context.py +8 -2
- package/harness/bundles/general/cursor/scripts/harness_events.py +5 -0
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +79 -2
- package/harness/bundles/general/cursor/scripts/harness_profile.py +7 -0
- package/harness/bundles/general/cursor/scripts/harness_state.py +22 -3
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/README.md +14 -16
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +43 -46
- package/harness/bundles/java/claude-code/harness-archive/checklist.md +10 -10
- package/harness/bundles/java/claude-code/harness-archive/reference.md +29 -22
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +1 -7
- package/harness/bundles/java/claude-code/harness-codebase-map/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 +2 -2
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +5 -3
- package/harness/bundles/java/claude-code/harness-run/protocols.md +2 -0
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +2 -2
- package/harness/bundles/java/claude-code/harness-test/checklist.md +2 -2
- package/harness/bundles/java/claude-code/harness-test/pitfalls.md +3 -3
- package/harness/bundles/java/claude-code/harness-test/reference.md +2 -2
- package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +18 -25
- package/harness/bundles/java/claude-code/protocols/evidence-based-reporting-protocol.md +5 -5
- package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +7 -7
- package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +10 -9
- package/harness/bundles/java/claude-code/protocols/sensitive-info-protocol.md +3 -3
- package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +5 -6
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +1043 -1108
- package/harness/bundles/java/claude-code/scripts/harness_context.py +8 -2
- package/harness/bundles/java/claude-code/scripts/harness_events.py +5 -0
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +79 -2
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +7 -0
- package/harness/bundles/java/claude-code/scripts/harness_state.py +22 -3
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/README.md +14 -16
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +43 -46
- package/harness/bundles/java/codebuddy/harness-archive/checklist.md +10 -10
- package/harness/bundles/java/codebuddy/harness-archive/reference.md +29 -22
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +1 -7
- package/harness/bundles/java/codebuddy/harness-codebase-map/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 +2 -2
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +5 -3
- package/harness/bundles/java/codebuddy/harness-run/protocols.md +2 -0
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-test/checklist.md +2 -2
- package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +3 -3
- package/harness/bundles/java/codebuddy/harness-test/reference.md +2 -2
- package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +18 -25
- package/harness/bundles/java/codebuddy/protocols/evidence-based-reporting-protocol.md +5 -5
- package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +7 -7
- package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +10 -9
- package/harness/bundles/java/codebuddy/protocols/sensitive-info-protocol.md +3 -3
- package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +5 -6
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +1043 -1108
- package/harness/bundles/java/codebuddy/scripts/harness_context.py +8 -2
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +5 -0
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +79 -2
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +7 -0
- package/harness/bundles/java/codebuddy/scripts/harness_state.py +22 -3
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/README.md +14 -16
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-archive/SKILL.md +43 -46
- package/harness/bundles/java/codex/harness-archive/checklist.md +10 -10
- package/harness/bundles/java/codex/harness-archive/reference.md +29 -22
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +1 -7
- package/harness/bundles/java/codex/harness-codebase-map/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 +2 -2
- package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-run/SKILL.md +5 -3
- package/harness/bundles/java/codex/harness-run/protocols.md +2 -0
- package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-test/checklist.md +2 -2
- package/harness/bundles/java/codex/harness-test/pitfalls.md +3 -3
- package/harness/bundles/java/codex/harness-test/reference.md +2 -2
- package/harness/bundles/java/codex/protocols/archive-report-protocol.md +18 -25
- package/harness/bundles/java/codex/protocols/evidence-based-reporting-protocol.md +5 -5
- package/harness/bundles/java/codex/protocols/ledger-protocol.md +7 -7
- package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +10 -9
- package/harness/bundles/java/codex/protocols/sensitive-info-protocol.md +3 -3
- package/harness/bundles/java/codex/protocols/state-layout-protocol.md +5 -6
- package/harness/bundles/java/codex/scripts/harness_archive.py +1043 -1108
- package/harness/bundles/java/codex/scripts/harness_context.py +8 -2
- package/harness/bundles/java/codex/scripts/harness_events.py +5 -0
- package/harness/bundles/java/codex/scripts/harness_ledger.py +79 -2
- package/harness/bundles/java/codex/scripts/harness_profile.py +7 -0
- package/harness/bundles/java/codex/scripts/harness_state.py +22 -3
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/README.md +14 -16
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +43 -46
- package/harness/bundles/java/cursor/harness-archive/checklist.md +10 -10
- package/harness/bundles/java/cursor/harness-archive/reference.md +29 -22
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +1 -7
- package/harness/bundles/java/cursor/harness-codebase-map/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 +2 -2
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-run/SKILL.md +5 -3
- package/harness/bundles/java/cursor/harness-run/protocols.md +2 -0
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-test/checklist.md +2 -2
- package/harness/bundles/java/cursor/harness-test/pitfalls.md +3 -3
- package/harness/bundles/java/cursor/harness-test/reference.md +2 -2
- package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +18 -25
- package/harness/bundles/java/cursor/protocols/evidence-based-reporting-protocol.md +5 -5
- package/harness/bundles/java/cursor/protocols/ledger-protocol.md +7 -7
- package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +10 -9
- package/harness/bundles/java/cursor/protocols/sensitive-info-protocol.md +3 -3
- package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +5 -6
- package/harness/bundles/java/cursor/scripts/harness_archive.py +1043 -1108
- package/harness/bundles/java/cursor/scripts/harness_context.py +8 -2
- package/harness/bundles/java/cursor/scripts/harness_events.py +5 -0
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +79 -2
- package/harness/bundles/java/cursor/scripts/harness_profile.py +7 -0
- package/harness/bundles/java/cursor/scripts/harness_state.py +22 -3
- package/harness/manifests/general/claude-code.json +35 -39
- package/harness/manifests/general/codebuddy.json +35 -39
- package/harness/manifests/general/codex.json +35 -39
- package/harness/manifests/general/cursor.json +35 -39
- package/harness/manifests/java/claude-code.json +35 -39
- package/harness/manifests/java/codebuddy.json +35 -39
- package/harness/manifests/java/codex.json +35 -39
- package/harness/manifests/java/cursor.json +35 -39
- package/hunter-workflow-family.json +5 -5
- package/package.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +0 -299
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +0 -299
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +0 -299
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +0 -299
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +0 -299
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +0 -299
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +0 -299
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +0 -299
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
Subcommands:
|
|
5
5
|
status — pre-archive gate checks (read-only)
|
|
6
|
-
finalize — single-process archive: manifest → move → collect →
|
|
7
|
-
|
|
6
|
+
finalize — single-process archive: manifest → move → collect → validate →
|
|
7
|
+
after-manifest → publish → ZIP upload/service
|
|
8
8
|
replay — read-only re-collect + validate for historical archives
|
|
9
9
|
|
|
10
10
|
Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
|
|
@@ -27,6 +27,7 @@ import subprocess
|
|
|
27
27
|
import sys
|
|
28
28
|
import tarfile
|
|
29
29
|
import tempfile
|
|
30
|
+
import time
|
|
30
31
|
import uuid
|
|
31
32
|
import zipfile
|
|
32
33
|
from collections.abc import Mapping
|
|
@@ -43,7 +44,6 @@ if hasattr(sys.stderr, "reconfigure"):
|
|
|
43
44
|
|
|
44
45
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
45
46
|
SKILLS_ROOT = SCRIPTS_DIR.parent
|
|
46
|
-
RENDER_SCRIPT = SKILLS_ROOT / "harness-archive" / "templates" / "render-summary.mjs"
|
|
47
47
|
SUMMARY_TEMPLATE = (
|
|
48
48
|
SKILLS_ROOT / "harness-archive" / "templates" / "summary-data-template.json"
|
|
49
49
|
)
|
|
@@ -1908,8 +1908,13 @@ def find_test_reports(change_dir: Path) -> list[Path]:
|
|
|
1908
1908
|
"reports/test/*.md",
|
|
1909
1909
|
]
|
|
1910
1910
|
found: list[Path] = []
|
|
1911
|
+
seen: set[Path] = set()
|
|
1911
1912
|
for pattern in patterns:
|
|
1912
|
-
|
|
1913
|
+
for path in sorted(change_dir.glob(pattern)):
|
|
1914
|
+
if path in seen:
|
|
1915
|
+
continue
|
|
1916
|
+
seen.add(path)
|
|
1917
|
+
found.append(path)
|
|
1913
1918
|
return found
|
|
1914
1919
|
|
|
1915
1920
|
|
|
@@ -2185,6 +2190,21 @@ def check_status(
|
|
|
2185
2190
|
checks["project_root"] = str(project)
|
|
2186
2191
|
state_root = resolve_archive_state_root(change_dir)
|
|
2187
2192
|
checks["state_root"] = str(state_root)
|
|
2193
|
+
range_adoption = validate_existing_range_adoption(change_dir)
|
|
2194
|
+
checks["range_adoption"] = range_adoption
|
|
2195
|
+
if not range_adoption.get("ok"):
|
|
2196
|
+
blockers.append(
|
|
2197
|
+
{
|
|
2198
|
+
"code": str(
|
|
2199
|
+
range_adoption.get("reasonCode")
|
|
2200
|
+
or "EXISTING_RANGE_RECEIPT_INVALID"
|
|
2201
|
+
),
|
|
2202
|
+
"message": str(
|
|
2203
|
+
range_adoption.get("message")
|
|
2204
|
+
or "既有提交范围认领收据无效。"
|
|
2205
|
+
),
|
|
2206
|
+
}
|
|
2207
|
+
)
|
|
2188
2208
|
|
|
2189
2209
|
exact_byte = check_archive_exact_byte_policy(project)
|
|
2190
2210
|
checks["archive_exact_byte"] = exact_byte
|
|
@@ -2203,14 +2223,33 @@ def check_status(
|
|
|
2203
2223
|
)
|
|
2204
2224
|
checks["sensitive_evidence_publication"] = sensitive_gate
|
|
2205
2225
|
if not sensitive_gate.get("ok"):
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
"
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
}
|
|
2226
|
+
sensitive_issue_codes = {
|
|
2227
|
+
str(item.get("code") or "")
|
|
2228
|
+
for item in sensitive_gate.get("issues") or []
|
|
2229
|
+
if isinstance(item, dict)
|
|
2230
|
+
}
|
|
2231
|
+
digest_only_drift = sensitive_issue_codes == {
|
|
2232
|
+
"SECRET_SCAN_TREE_DIGEST_MISMATCH"
|
|
2233
|
+
}
|
|
2234
|
+
if digest_only_drift and not sensitive_gate.get("unresolvedFailures"):
|
|
2235
|
+
warnings.append(
|
|
2236
|
+
{
|
|
2237
|
+
"code": "SECRET_SCAN_RECEIPT_REFRESH_REQUIRED",
|
|
2238
|
+
"message": (
|
|
2239
|
+
"归档事件或锁文件变化使敏感信息扫描收据过期;"
|
|
2240
|
+
"正式归档会重新扫描并绑定当前字节。"
|
|
2241
|
+
),
|
|
2242
|
+
}
|
|
2243
|
+
)
|
|
2244
|
+
else:
|
|
2245
|
+
blockers.append({
|
|
2246
|
+
"code": str(sensitive_gate.get("reasonCode") or "SECRET_SCAN_GATE_BLOCKED"),
|
|
2247
|
+
"message": str(
|
|
2248
|
+
sensitive_gate.get("nextAction")
|
|
2249
|
+
or sensitive_gate.get("error")
|
|
2250
|
+
or "sensitive evidence publication gate failed"
|
|
2251
|
+
),
|
|
2252
|
+
})
|
|
2214
2253
|
|
|
2215
2254
|
# --- H-4 formal-layer minimum set ---
|
|
2216
2255
|
plans_dir = change_dir / "plans"
|
|
@@ -2239,6 +2278,28 @@ def check_status(
|
|
|
2239
2278
|
|
|
2240
2279
|
ledger = load_ledger(change_dir)
|
|
2241
2280
|
checks["verification_ledger"] = ledger is not None
|
|
2281
|
+
immutable_base = _immutable_change_base_from_snapshot(change_dir)
|
|
2282
|
+
ledger_base = (
|
|
2283
|
+
str(ledger.get("baseCommit") or "").strip()
|
|
2284
|
+
if isinstance(ledger, dict)
|
|
2285
|
+
else ""
|
|
2286
|
+
)
|
|
2287
|
+
checks["change_base"] = immutable_base or None
|
|
2288
|
+
if (
|
|
2289
|
+
immutable_base
|
|
2290
|
+
and ledger_base
|
|
2291
|
+
and immutable_base != ledger_base
|
|
2292
|
+
and not range_adoption.get("present")
|
|
2293
|
+
):
|
|
2294
|
+
blockers.append(
|
|
2295
|
+
{
|
|
2296
|
+
"code": "CHANGE_BASE_IDENTITY_MISMATCH",
|
|
2297
|
+
"message": (
|
|
2298
|
+
"计划阶段保存的变更基线与验证账本不一致;"
|
|
2299
|
+
"请先恢复正确身份,不得在归档阶段自动改写。"
|
|
2300
|
+
),
|
|
2301
|
+
}
|
|
2302
|
+
)
|
|
2242
2303
|
if ledger is None:
|
|
2243
2304
|
target = warnings if closure_disposition != "completed" else blockers
|
|
2244
2305
|
target.append(
|
|
@@ -2297,7 +2358,10 @@ def check_status(
|
|
|
2297
2358
|
hash_source: str | None = None
|
|
2298
2359
|
if ledger is None:
|
|
2299
2360
|
ledger = load_ledger(change_dir)
|
|
2300
|
-
if
|
|
2361
|
+
if range_adoption.get("ok") and range_adoption.get("present"):
|
|
2362
|
+
expected_hash = str(range_adoption.get("tipCommit") or "").strip() or None
|
|
2363
|
+
hash_source = "meta/archive-range-adoption.json"
|
|
2364
|
+
if expected_hash is None and worktree_requested(change_dir) and ledger:
|
|
2301
2365
|
merge_hash = ledger.get("mergeFinalHash") or (
|
|
2302
2366
|
ledger.get("merge") or {}
|
|
2303
2367
|
).get("finalHash")
|
|
@@ -2493,7 +2557,6 @@ def check_status(
|
|
|
2493
2557
|
),
|
|
2494
2558
|
})
|
|
2495
2559
|
|
|
2496
|
-
archivable = len(blockers) == 0
|
|
2497
2560
|
candidate_codes = {
|
|
2498
2561
|
"PRODUCT_CI_NOT_GREEN",
|
|
2499
2562
|
"PRODUCT_CANDIDATE_NOT_VERIFIED",
|
|
@@ -2501,6 +2564,71 @@ def check_status(
|
|
|
2501
2564
|
"PROJECT_RELEASE_POLICY_BLOCKED",
|
|
2502
2565
|
"REMOTE_CI_DOWNGRADE_REFUSED",
|
|
2503
2566
|
}
|
|
2567
|
+
report_adequacy: dict[str, Any]
|
|
2568
|
+
release_summary: dict[str, Any] | None = None
|
|
2569
|
+
try:
|
|
2570
|
+
release_summary = collect_summary_data(
|
|
2571
|
+
change_dir, write=False, for_replay=False
|
|
2572
|
+
)
|
|
2573
|
+
release_summary["archiveIntent"] = archive_intent
|
|
2574
|
+
release_summary["closureDisposition"] = closure_disposition
|
|
2575
|
+
release_summary["closureReason"] = closure_reason
|
|
2576
|
+
report_adequacy = validate_report_adequacy(release_summary)
|
|
2577
|
+
except Exception as exc: # noqa: BLE001 — status must remain read-only
|
|
2578
|
+
report_adequacy = {
|
|
2579
|
+
"ok": False,
|
|
2580
|
+
"code": "REPORT_ADEQUACY_UNAVAILABLE",
|
|
2581
|
+
"message": str(exc),
|
|
2582
|
+
"issues": [
|
|
2583
|
+
{
|
|
2584
|
+
"code": "REPORT_ADEQUACY_UNAVAILABLE",
|
|
2585
|
+
"severity": "error",
|
|
2586
|
+
"message": str(exc),
|
|
2587
|
+
}
|
|
2588
|
+
],
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
adequacy_messages_zh = {
|
|
2592
|
+
"ARCHIVE_BASE_EQUALS_FEATURE_TIP": (
|
|
2593
|
+
"变更基线与产品提交相同,本次没有可归档的产品增量。"
|
|
2594
|
+
"如需封存已有提交,请先使用受控的既有范围认领;"
|
|
2595
|
+
"如变更未完成,请选择废弃或被替代并填写中文原因。"
|
|
2596
|
+
),
|
|
2597
|
+
"IDENTITY_BASE_MISSING": "缺少不可变的变更基线,无法证明归档范围。",
|
|
2598
|
+
"DIFF_ZERO_WITH_NONEMPTY_COMMIT": "提交范围非空,但报告中的文件变更数为 0。",
|
|
2599
|
+
"ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP": "归档差异小于声明的产品范围。",
|
|
2600
|
+
"ARCHIVE_NOFF_MERGE_DELTA_ONLY": "归档只覆盖了合并提交差异,未覆盖完整变更。",
|
|
2601
|
+
"REPORT_ADEQUACY_UNAVAILABLE": "无法生成归档充分性报告。",
|
|
2602
|
+
}
|
|
2603
|
+
for issue in report_adequacy.get("issues") or []:
|
|
2604
|
+
if not isinstance(issue, dict) or issue.get("severity") != "error":
|
|
2605
|
+
continue
|
|
2606
|
+
code = str(issue.get("code") or "REPORT_ADEQUACY_FAILED")
|
|
2607
|
+
if any(item.get("code") == code for item in blockers):
|
|
2608
|
+
continue
|
|
2609
|
+
blockers.append(
|
|
2610
|
+
{
|
|
2611
|
+
"code": code,
|
|
2612
|
+
"message": adequacy_messages_zh.get(
|
|
2613
|
+
code,
|
|
2614
|
+
str(issue.get("message") or "归档报告不完整"),
|
|
2615
|
+
),
|
|
2616
|
+
}
|
|
2617
|
+
)
|
|
2618
|
+
for issue in report_adequacy.get("issues") or []:
|
|
2619
|
+
if not isinstance(issue, dict) or issue.get("severity") != "warning":
|
|
2620
|
+
continue
|
|
2621
|
+
code = str(issue.get("code") or "REPORT_ADEQUACY_WARNING")
|
|
2622
|
+
if any(item.get("code") == code for item in warnings):
|
|
2623
|
+
continue
|
|
2624
|
+
warnings.append(
|
|
2625
|
+
{
|
|
2626
|
+
"code": code,
|
|
2627
|
+
"message": str(issue.get("message") or "归档报告需要注意"),
|
|
2628
|
+
}
|
|
2629
|
+
)
|
|
2630
|
+
|
|
2631
|
+
archivable = len(blockers) == 0
|
|
2504
2632
|
archive_integrity_ok = not any(
|
|
2505
2633
|
item.get("code") not in candidate_codes for item in blockers
|
|
2506
2634
|
)
|
|
@@ -2512,34 +2640,23 @@ def check_status(
|
|
|
2512
2640
|
else "ARCHIVE_INTEGRITY_FAILED"
|
|
2513
2641
|
),
|
|
2514
2642
|
"message": (
|
|
2515
|
-
"
|
|
2643
|
+
"归档前置条件完整"
|
|
2516
2644
|
if archive_integrity_ok
|
|
2517
|
-
else "
|
|
2645
|
+
else "归档前置条件存在阻断项"
|
|
2518
2646
|
),
|
|
2519
2647
|
}
|
|
2520
|
-
|
|
2521
|
-
release_summary = collect_summary_data(
|
|
2522
|
-
change_dir, write=False, for_replay=False
|
|
2523
|
-
)
|
|
2524
|
-
release_summary["archiveIntent"] = archive_intent
|
|
2525
|
-
release_summary["closureDisposition"] = closure_disposition
|
|
2526
|
-
release_summary["closureReason"] = closure_reason
|
|
2527
|
-
report_adequacy = validate_report_adequacy(release_summary)
|
|
2648
|
+
if release_summary is not None:
|
|
2528
2649
|
release_decision = evaluate_release_eligibility(
|
|
2529
2650
|
change_dir,
|
|
2530
2651
|
release_summary,
|
|
2531
2652
|
archive_integrity=archive_integrity,
|
|
2532
2653
|
report_adequacy=report_adequacy,
|
|
2533
2654
|
)
|
|
2534
|
-
|
|
2655
|
+
else:
|
|
2535
2656
|
release_decision = compose_release_decision(
|
|
2536
2657
|
{
|
|
2537
2658
|
"archiveIntegrity": archive_integrity,
|
|
2538
|
-
"reportAdequacy":
|
|
2539
|
-
"ok": False,
|
|
2540
|
-
"code": "REPORT_ADEQUACY_UNAVAILABLE",
|
|
2541
|
-
"message": str(exc),
|
|
2542
|
-
},
|
|
2659
|
+
"reportAdequacy": report_adequacy,
|
|
2543
2660
|
"candidateVerification": ci_gate,
|
|
2544
2661
|
}
|
|
2545
2662
|
)
|
|
@@ -2573,21 +2690,37 @@ def archive_auto_gate(
|
|
|
2573
2690
|
*,
|
|
2574
2691
|
allow_missing_review: bool = False,
|
|
2575
2692
|
archive_intent: str = "release-candidate",
|
|
2693
|
+
closure_disposition: str = "completed",
|
|
2694
|
+
closure_reason: str = "",
|
|
2576
2695
|
) -> dict[str, Any]:
|
|
2577
2696
|
"""Read-only proof that a post-submit/merge archive may run unattended."""
|
|
2578
2697
|
status = check_status(
|
|
2579
2698
|
change_dir,
|
|
2580
2699
|
allow_missing_review=allow_missing_review,
|
|
2581
2700
|
archive_intent=archive_intent,
|
|
2701
|
+
closure_disposition=closure_disposition,
|
|
2702
|
+
closure_reason=closure_reason,
|
|
2582
2703
|
)
|
|
2583
2704
|
if not status.get("archivable"):
|
|
2705
|
+
report_adequacy = (
|
|
2706
|
+
(status.get("releaseDecision") or {}).get("checks") or {}
|
|
2707
|
+
).get("reportAdequacy") or {}
|
|
2708
|
+
adequacy_failed = report_adequacy.get("ok") is False
|
|
2584
2709
|
return {
|
|
2585
2710
|
"ok": False,
|
|
2586
2711
|
"action": "auto-gate",
|
|
2587
2712
|
"autoArchiveAllowed": False,
|
|
2588
|
-
"reasonCode":
|
|
2713
|
+
"reasonCode": (
|
|
2714
|
+
"ARCHIVE_REPORT_ADEQUACY_FAILED"
|
|
2715
|
+
if adequacy_failed
|
|
2716
|
+
else "ARCHIVE_PRECONDITIONS_UNSATISFIED"
|
|
2717
|
+
),
|
|
2589
2718
|
"status": status,
|
|
2590
|
-
"nextAction":
|
|
2719
|
+
"nextAction": (
|
|
2720
|
+
"归档报告不完整,请按 blockers 中的中文建议处理后重试。"
|
|
2721
|
+
if adequacy_failed
|
|
2722
|
+
else "请先处理归档阻断项,再重新执行归档。"
|
|
2723
|
+
),
|
|
2591
2724
|
}
|
|
2592
2725
|
|
|
2593
2726
|
state_root = resolve_archive_state_root(change_dir)
|
|
@@ -2609,7 +2742,7 @@ def archive_auto_gate(
|
|
|
2609
2742
|
}
|
|
2610
2743
|
|
|
2611
2744
|
git_state = snapshot.get("git")
|
|
2612
|
-
snapshot_base = (
|
|
2745
|
+
snapshot_base = snapshot.get("changeBase") or (
|
|
2613
2746
|
(git_state.get("base") or git_state.get("baseCommit"))
|
|
2614
2747
|
if isinstance(git_state, dict)
|
|
2615
2748
|
else snapshot.get("base") or snapshot.get("baseCommit")
|
|
@@ -2685,10 +2818,131 @@ def archive_auto_gate(
|
|
|
2685
2818
|
"status": status,
|
|
2686
2819
|
"snapshotPath": str(snapshot_path),
|
|
2687
2820
|
"snapshotBase": snapshot_base,
|
|
2688
|
-
"nextAction": "
|
|
2821
|
+
"nextAction": "运行 `harness_archive.py execute`;它会复用本次状态结果,不再重复扫描。",
|
|
2689
2822
|
}
|
|
2690
2823
|
|
|
2691
2824
|
|
|
2825
|
+
def execute_archive(
|
|
2826
|
+
change_dir: Path,
|
|
2827
|
+
archive_root: Path,
|
|
2828
|
+
*,
|
|
2829
|
+
durable_root: Path | None = None,
|
|
2830
|
+
retention_policy: str = "unspecified",
|
|
2831
|
+
skip_ingest: bool = False,
|
|
2832
|
+
allow_missing_review: bool = False,
|
|
2833
|
+
archive_intent: str = "release-candidate",
|
|
2834
|
+
closure_disposition: str = "completed",
|
|
2835
|
+
closure_reason: str = "",
|
|
2836
|
+
) -> tuple[int, dict[str, Any]]:
|
|
2837
|
+
"""Run one status collection, boundary gate, and finalize operation."""
|
|
2838
|
+
change_dir = change_dir.resolve()
|
|
2839
|
+
started = time.perf_counter()
|
|
2840
|
+
try:
|
|
2841
|
+
state_root = resolve_archive_state_root(change_dir)
|
|
2842
|
+
except (OSError, ValueError):
|
|
2843
|
+
state_root = change_dir
|
|
2844
|
+
if state_root.is_dir():
|
|
2845
|
+
try:
|
|
2846
|
+
append_event(
|
|
2847
|
+
state_root,
|
|
2848
|
+
phase="archive",
|
|
2849
|
+
type_="phase.prepare.start",
|
|
2850
|
+
note="开始一次性检查归档条件",
|
|
2851
|
+
)
|
|
2852
|
+
except OSError:
|
|
2853
|
+
pass
|
|
2854
|
+
candidate_certification: dict[str, Any] | None = None
|
|
2855
|
+
if load_product_candidate_ci(change_dir) is None:
|
|
2856
|
+
try:
|
|
2857
|
+
receipt = certify_local_candidate(
|
|
2858
|
+
change_dir,
|
|
2859
|
+
project=find_project_root(change_dir),
|
|
2860
|
+
)
|
|
2861
|
+
subject = (
|
|
2862
|
+
receipt.get("subject")
|
|
2863
|
+
if isinstance(receipt.get("subject"), dict)
|
|
2864
|
+
else {}
|
|
2865
|
+
)
|
|
2866
|
+
candidate_certification = {
|
|
2867
|
+
"ok": True,
|
|
2868
|
+
"code": "LOCAL_CANDIDATE_CERTIFIED",
|
|
2869
|
+
"assurance": receipt.get("assurance"),
|
|
2870
|
+
"subject": subject,
|
|
2871
|
+
"reusedValidations": (
|
|
2872
|
+
receipt.get("verification", {}).get("reusedValidations", [])
|
|
2873
|
+
if isinstance(receipt.get("verification"), dict)
|
|
2874
|
+
else []
|
|
2875
|
+
),
|
|
2876
|
+
}
|
|
2877
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
2878
|
+
candidate_certification = {
|
|
2879
|
+
"ok": False,
|
|
2880
|
+
"code": "LOCAL_CANDIDATE_CERTIFICATION_UNAVAILABLE",
|
|
2881
|
+
"message": str(exc),
|
|
2882
|
+
}
|
|
2883
|
+
gate = archive_auto_gate(
|
|
2884
|
+
change_dir,
|
|
2885
|
+
allow_missing_review=allow_missing_review,
|
|
2886
|
+
archive_intent=archive_intent,
|
|
2887
|
+
closure_disposition=closure_disposition,
|
|
2888
|
+
closure_reason=closure_reason,
|
|
2889
|
+
)
|
|
2890
|
+
elapsed_ms = max(0, int(round((time.perf_counter() - started) * 1000)))
|
|
2891
|
+
if state_root.is_dir():
|
|
2892
|
+
try:
|
|
2893
|
+
append_event(
|
|
2894
|
+
state_root,
|
|
2895
|
+
phase="archive",
|
|
2896
|
+
type_="phase.prepare.end",
|
|
2897
|
+
status="OK" if gate.get("ok") else "BLOCKED",
|
|
2898
|
+
code=str(gate.get("reasonCode") or "ARCHIVE_PREPARE_FINISHED"),
|
|
2899
|
+
message=(
|
|
2900
|
+
"归档条件检查通过"
|
|
2901
|
+
if gate.get("ok")
|
|
2902
|
+
else str(gate.get("nextAction") or "归档条件未满足")
|
|
2903
|
+
),
|
|
2904
|
+
duration_ms=elapsed_ms,
|
|
2905
|
+
note="一次性归档预检完成",
|
|
2906
|
+
)
|
|
2907
|
+
except OSError:
|
|
2908
|
+
pass
|
|
2909
|
+
if not gate.get("ok"):
|
|
2910
|
+
status = gate.get("status") if isinstance(gate.get("status"), dict) else {}
|
|
2911
|
+
blocked_payload = {
|
|
2912
|
+
"ok": False,
|
|
2913
|
+
"action": "execute",
|
|
2914
|
+
"reasonCode": gate.get("reasonCode"),
|
|
2915
|
+
"error": gate.get("nextAction"),
|
|
2916
|
+
"issues": list(status.get("blockers") or []),
|
|
2917
|
+
"preflight": gate,
|
|
2918
|
+
"change_dir": str(change_dir),
|
|
2919
|
+
"archive_dir": str(archive_root.resolve() / f"{today_date()}-{change_dir.name}"),
|
|
2920
|
+
"original_preserved": change_dir.is_dir(),
|
|
2921
|
+
"finalStatus": "BLOCKED",
|
|
2922
|
+
"preparationDurationMs": elapsed_ms,
|
|
2923
|
+
}
|
|
2924
|
+
if candidate_certification is not None:
|
|
2925
|
+
blocked_payload["candidateCertification"] = candidate_certification
|
|
2926
|
+
return 1, blocked_payload
|
|
2927
|
+
code, payload = cmd_finalize(
|
|
2928
|
+
change_dir,
|
|
2929
|
+
archive_root,
|
|
2930
|
+
durable_root=durable_root,
|
|
2931
|
+
retention_policy=retention_policy,
|
|
2932
|
+
skip_ingest=skip_ingest,
|
|
2933
|
+
allow_missing_review=allow_missing_review,
|
|
2934
|
+
archive_intent=archive_intent,
|
|
2935
|
+
closure_disposition=closure_disposition,
|
|
2936
|
+
closure_reason=closure_reason,
|
|
2937
|
+
preflight_status=gate["status"],
|
|
2938
|
+
)
|
|
2939
|
+
payload["preflight"] = gate
|
|
2940
|
+
payload["preparationDurationMs"] = elapsed_ms
|
|
2941
|
+
if candidate_certification is not None:
|
|
2942
|
+
payload["candidateCertification"] = candidate_certification
|
|
2943
|
+
return code, payload
|
|
2944
|
+
|
|
2945
|
+
|
|
2692
2946
|
# ---------------------------------------------------------------------------
|
|
2693
2947
|
# collect
|
|
2694
2948
|
# ---------------------------------------------------------------------------
|
|
@@ -3198,7 +3452,11 @@ def build_verification_projection(
|
|
|
3198
3452
|
"apiTests": _ledger_api_tests(effective_ledger, change_dir=change_dir),
|
|
3199
3453
|
}
|
|
3200
3454
|
db_compatibility = _ledger_db_compat(effective_ledger)
|
|
3201
|
-
if db_compatibility["status"]
|
|
3455
|
+
if db_compatibility["status"] in {
|
|
3456
|
+
"NOT_RUN",
|
|
3457
|
+
"UNKNOWN",
|
|
3458
|
+
"EVIDENCE_MISSING",
|
|
3459
|
+
} and change_dir is not None:
|
|
3202
3460
|
gate_policy_path = change_dir / "meta" / "gate-policy.json"
|
|
3203
3461
|
try:
|
|
3204
3462
|
gate_policy = read_json(gate_policy_path) if gate_policy_path.is_file() else None
|
|
@@ -3206,15 +3464,22 @@ def build_verification_projection(
|
|
|
3206
3464
|
gate_policy = None
|
|
3207
3465
|
if isinstance(gate_policy, dict):
|
|
3208
3466
|
capabilities = gate_policy.get("capabilities")
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3467
|
+
required_names: set[str] = set()
|
|
3468
|
+
for field in ("requiredValidations", "requiredValidationsByPhase"):
|
|
3469
|
+
required = gate_policy.get(field)
|
|
3470
|
+
if isinstance(required, list):
|
|
3471
|
+
required_names.update(str(item) for item in required)
|
|
3472
|
+
elif isinstance(required, dict):
|
|
3473
|
+
for phase_items in required.values():
|
|
3474
|
+
if isinstance(phase_items, list):
|
|
3475
|
+
required_names.update(str(item) for item in phase_items)
|
|
3476
|
+
has_database_capability = (
|
|
3477
|
+
isinstance(capabilities, list) and "database" in capabilities
|
|
3478
|
+
)
|
|
3479
|
+
if not has_database_capability and "dbCompatibility" not in required_names:
|
|
3215
3480
|
db_compatibility = {
|
|
3216
3481
|
"status": "NOT_APPLICABLE",
|
|
3217
|
-
"reason": "
|
|
3482
|
+
"reason": "门禁策略未声明数据库能力或数据库兼容性验证要求",
|
|
3218
3483
|
"source": "capability-profile",
|
|
3219
3484
|
}
|
|
3220
3485
|
projection["dbCompatibility"] = db_compatibility["status"]
|
|
@@ -3893,6 +4158,303 @@ def _review_summary(
|
|
|
3893
4158
|
return base
|
|
3894
4159
|
|
|
3895
4160
|
|
|
4161
|
+
def _archive_range_adoption_path(change_dir: Path) -> Path:
|
|
4162
|
+
return change_dir.resolve() / "meta" / "archive-range-adoption.json"
|
|
4163
|
+
|
|
4164
|
+
|
|
4165
|
+
def _archive_range_receipt_id(payload: dict[str, Any]) -> str:
|
|
4166
|
+
canonical = {
|
|
4167
|
+
key: value for key, value in payload.items() if key != "receiptId"
|
|
4168
|
+
}
|
|
4169
|
+
raw = json.dumps(
|
|
4170
|
+
canonical,
|
|
4171
|
+
ensure_ascii=False,
|
|
4172
|
+
sort_keys=True,
|
|
4173
|
+
separators=(",", ":"),
|
|
4174
|
+
).encode("utf-8")
|
|
4175
|
+
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
4176
|
+
|
|
4177
|
+
|
|
4178
|
+
def _evaluate_existing_range(
|
|
4179
|
+
change_dir: Path,
|
|
4180
|
+
*,
|
|
4181
|
+
base: str,
|
|
4182
|
+
tip: str,
|
|
4183
|
+
) -> dict[str, Any]:
|
|
4184
|
+
project = find_project_root(change_dir)
|
|
4185
|
+
base_code, resolved_base, base_error = git_run(
|
|
4186
|
+
project, "rev-parse", "--verify", f"{base}^{{commit}}"
|
|
4187
|
+
)
|
|
4188
|
+
tip_code, resolved_tip, tip_error = git_run(
|
|
4189
|
+
project, "rev-parse", "--verify", f"{tip}^{{commit}}"
|
|
4190
|
+
)
|
|
4191
|
+
if base_code != 0 or not resolved_base:
|
|
4192
|
+
return {
|
|
4193
|
+
"ok": False,
|
|
4194
|
+
"reasonCode": "EXISTING_RANGE_BASE_INVALID",
|
|
4195
|
+
"message": f"无法解析既有范围基线:{base_error or base}",
|
|
4196
|
+
}
|
|
4197
|
+
if tip_code != 0 or not resolved_tip:
|
|
4198
|
+
return {
|
|
4199
|
+
"ok": False,
|
|
4200
|
+
"reasonCode": "EXISTING_RANGE_TIP_INVALID",
|
|
4201
|
+
"message": f"无法解析既有范围终点:{tip_error or tip}",
|
|
4202
|
+
}
|
|
4203
|
+
if resolved_base == resolved_tip:
|
|
4204
|
+
return {
|
|
4205
|
+
"ok": False,
|
|
4206
|
+
"reasonCode": "EXISTING_RANGE_EMPTY",
|
|
4207
|
+
"message": "既有提交范围为空,不能认领为已完成产品变更。",
|
|
4208
|
+
}
|
|
4209
|
+
ancestor_code, _, ancestor_error = git_run(
|
|
4210
|
+
project, "merge-base", "--is-ancestor", resolved_base, resolved_tip
|
|
4211
|
+
)
|
|
4212
|
+
if ancestor_code != 0:
|
|
4213
|
+
return {
|
|
4214
|
+
"ok": False,
|
|
4215
|
+
"reasonCode": "EXISTING_RANGE_NOT_ANCESTOR",
|
|
4216
|
+
"message": f"范围基线不是终点的祖先:{ancestor_error or resolved_base}",
|
|
4217
|
+
}
|
|
4218
|
+
identity = resolve_product_archive_identity(change_dir, project=project)
|
|
4219
|
+
expected_tip = str(
|
|
4220
|
+
identity.get("featureTip") or identity.get("productCommit") or ""
|
|
4221
|
+
).strip()
|
|
4222
|
+
if expected_tip:
|
|
4223
|
+
expected_code, resolved_expected, _ = git_run(
|
|
4224
|
+
project, "rev-parse", "--verify", f"{expected_tip}^{{commit}}"
|
|
4225
|
+
)
|
|
4226
|
+
if expected_code == 0 and resolved_expected != resolved_tip:
|
|
4227
|
+
return {
|
|
4228
|
+
"ok": False,
|
|
4229
|
+
"reasonCode": "EXISTING_RANGE_TIP_MISMATCH",
|
|
4230
|
+
"message": (
|
|
4231
|
+
"既有范围终点与当前产品提交不一致:"
|
|
4232
|
+
f"expected={resolved_expected} actual={resolved_tip}"
|
|
4233
|
+
),
|
|
4234
|
+
}
|
|
4235
|
+
try:
|
|
4236
|
+
ownership = hl.compute_ownership_diff(
|
|
4237
|
+
project,
|
|
4238
|
+
base=resolved_base,
|
|
4239
|
+
head=resolved_tip,
|
|
4240
|
+
change_dir=change_dir,
|
|
4241
|
+
)
|
|
4242
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
4243
|
+
return {
|
|
4244
|
+
"ok": False,
|
|
4245
|
+
"reasonCode": "EXISTING_RANGE_OWNERSHIP_UNAVAILABLE",
|
|
4246
|
+
"message": f"无法验证既有范围归属:{exc}",
|
|
4247
|
+
}
|
|
4248
|
+
foreign = list(ownership.get("foreignPaths") or [])
|
|
4249
|
+
owned = list(ownership.get("files") or [])
|
|
4250
|
+
if foreign:
|
|
4251
|
+
return {
|
|
4252
|
+
"ok": False,
|
|
4253
|
+
"reasonCode": "EXISTING_RANGE_FOREIGN_PATHS",
|
|
4254
|
+
"message": "既有范围包含不属于本变更的文件。",
|
|
4255
|
+
"foreignPaths": foreign,
|
|
4256
|
+
}
|
|
4257
|
+
if not owned:
|
|
4258
|
+
return {
|
|
4259
|
+
"ok": False,
|
|
4260
|
+
"reasonCode": "EXISTING_RANGE_NO_PRODUCT_FILES",
|
|
4261
|
+
"message": "既有范围未包含本变更声明的产品文件。",
|
|
4262
|
+
}
|
|
4263
|
+
return {
|
|
4264
|
+
"ok": True,
|
|
4265
|
+
"reasonCode": "EXISTING_RANGE_VALID",
|
|
4266
|
+
"project": project,
|
|
4267
|
+
"baseCommit": resolved_base,
|
|
4268
|
+
"tipCommit": resolved_tip,
|
|
4269
|
+
"ownership": ownership,
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
|
|
4273
|
+
def validate_existing_range_adoption(change_dir: Path) -> dict[str, Any]:
|
|
4274
|
+
path = _archive_range_adoption_path(change_dir)
|
|
4275
|
+
if not path.is_file():
|
|
4276
|
+
return {
|
|
4277
|
+
"ok": True,
|
|
4278
|
+
"present": False,
|
|
4279
|
+
"reasonCode": "EXISTING_RANGE_NOT_ADOPTED",
|
|
4280
|
+
"receiptPath": str(path),
|
|
4281
|
+
}
|
|
4282
|
+
try:
|
|
4283
|
+
receipt = read_json(path)
|
|
4284
|
+
except (OSError, json.JSONDecodeError, TypeError) as exc:
|
|
4285
|
+
return {
|
|
4286
|
+
"ok": False,
|
|
4287
|
+
"present": True,
|
|
4288
|
+
"reasonCode": "EXISTING_RANGE_RECEIPT_INVALID",
|
|
4289
|
+
"message": str(exc),
|
|
4290
|
+
"receiptPath": str(path),
|
|
4291
|
+
}
|
|
4292
|
+
if not isinstance(receipt, dict):
|
|
4293
|
+
return {
|
|
4294
|
+
"ok": False,
|
|
4295
|
+
"present": True,
|
|
4296
|
+
"reasonCode": "EXISTING_RANGE_RECEIPT_INVALID",
|
|
4297
|
+
"message": "既有范围认领收据必须是 JSON 对象。",
|
|
4298
|
+
"receiptPath": str(path),
|
|
4299
|
+
}
|
|
4300
|
+
issues: list[str] = []
|
|
4301
|
+
if receipt.get("schemaVersion") != 1:
|
|
4302
|
+
issues.append("schemaVersion")
|
|
4303
|
+
if receipt.get("action") != "adopt-existing-range":
|
|
4304
|
+
issues.append("action")
|
|
4305
|
+
if receipt.get("changeName") != change_dir.name:
|
|
4306
|
+
issues.append("changeName")
|
|
4307
|
+
if receipt.get("confirmedByUser") is not True:
|
|
4308
|
+
issues.append("confirmedByUser")
|
|
4309
|
+
reason = str(receipt.get("reason") or "").strip()
|
|
4310
|
+
if not reason or re.search(r"[\u4e00-\u9fff]", reason) is None:
|
|
4311
|
+
issues.append("reason")
|
|
4312
|
+
if receipt.get("receiptId") != _archive_range_receipt_id(receipt):
|
|
4313
|
+
issues.append("receiptId")
|
|
4314
|
+
if issues:
|
|
4315
|
+
return {
|
|
4316
|
+
"ok": False,
|
|
4317
|
+
"present": True,
|
|
4318
|
+
"reasonCode": "EXISTING_RANGE_RECEIPT_INVALID",
|
|
4319
|
+
"message": "既有范围认领收据字段无效:" + ", ".join(issues),
|
|
4320
|
+
"receiptPath": str(path),
|
|
4321
|
+
}
|
|
4322
|
+
evaluated = _evaluate_existing_range(
|
|
4323
|
+
change_dir,
|
|
4324
|
+
base=str(receipt.get("baseCommit") or ""),
|
|
4325
|
+
tip=str(receipt.get("tipCommit") or ""),
|
|
4326
|
+
)
|
|
4327
|
+
if not evaluated.get("ok"):
|
|
4328
|
+
return {
|
|
4329
|
+
**evaluated,
|
|
4330
|
+
"present": True,
|
|
4331
|
+
"receiptPath": str(path),
|
|
4332
|
+
}
|
|
4333
|
+
project = evaluated["project"]
|
|
4334
|
+
repository_id = hp.repository_identity(project)
|
|
4335
|
+
ownership = evaluated["ownership"]
|
|
4336
|
+
if receipt.get("repositoryId") != repository_id:
|
|
4337
|
+
issues.append("repositoryId")
|
|
4338
|
+
if receipt.get("diffHash") != ownership.get("diffHash"):
|
|
4339
|
+
issues.append("diffHash")
|
|
4340
|
+
if receipt.get("ownershipHash") != ownership.get("ownershipHash"):
|
|
4341
|
+
issues.append("ownershipHash")
|
|
4342
|
+
if list(receipt.get("files") or []) != list(ownership.get("files") or []):
|
|
4343
|
+
issues.append("files")
|
|
4344
|
+
if issues:
|
|
4345
|
+
return {
|
|
4346
|
+
"ok": False,
|
|
4347
|
+
"present": True,
|
|
4348
|
+
"reasonCode": "EXISTING_RANGE_RECEIPT_STALE",
|
|
4349
|
+
"message": "既有范围认领收据与当前仓库不一致:" + ", ".join(issues),
|
|
4350
|
+
"receiptPath": str(path),
|
|
4351
|
+
}
|
|
4352
|
+
return {
|
|
4353
|
+
"ok": True,
|
|
4354
|
+
"present": True,
|
|
4355
|
+
"reasonCode": "EXISTING_RANGE_ADOPTED",
|
|
4356
|
+
"receiptPath": str(path),
|
|
4357
|
+
"receipt": receipt,
|
|
4358
|
+
"baseCommit": evaluated["baseCommit"],
|
|
4359
|
+
"tipCommit": evaluated["tipCommit"],
|
|
4360
|
+
"files": list(ownership.get("files") or []),
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
|
|
4364
|
+
def adopt_existing_range(
|
|
4365
|
+
change_dir: Path,
|
|
4366
|
+
*,
|
|
4367
|
+
base: str,
|
|
4368
|
+
tip: str,
|
|
4369
|
+
reason: str,
|
|
4370
|
+
confirmed: bool,
|
|
4371
|
+
) -> dict[str, Any]:
|
|
4372
|
+
change_dir = change_dir.resolve()
|
|
4373
|
+
path = _archive_range_adoption_path(change_dir)
|
|
4374
|
+
if not confirmed:
|
|
4375
|
+
return {
|
|
4376
|
+
"ok": False,
|
|
4377
|
+
"action": "adopt-existing-range",
|
|
4378
|
+
"reasonCode": "EXISTING_RANGE_CONFIRMATION_REQUIRED",
|
|
4379
|
+
"message": "认领既有提交范围必须先获得用户明确确认。",
|
|
4380
|
+
"receiptPath": str(path),
|
|
4381
|
+
}
|
|
4382
|
+
reason = reason.strip()
|
|
4383
|
+
if not reason or re.search(r"[\u4e00-\u9fff]", reason) is None:
|
|
4384
|
+
return {
|
|
4385
|
+
"ok": False,
|
|
4386
|
+
"action": "adopt-existing-range",
|
|
4387
|
+
"reasonCode": "EXISTING_RANGE_REASON_REQUIRED",
|
|
4388
|
+
"message": "请提供可读的中文认领原因。",
|
|
4389
|
+
"receiptPath": str(path),
|
|
4390
|
+
}
|
|
4391
|
+
evaluated = _evaluate_existing_range(
|
|
4392
|
+
change_dir,
|
|
4393
|
+
base=base,
|
|
4394
|
+
tip=tip,
|
|
4395
|
+
)
|
|
4396
|
+
if not evaluated.get("ok"):
|
|
4397
|
+
return {
|
|
4398
|
+
**evaluated,
|
|
4399
|
+
"action": "adopt-existing-range",
|
|
4400
|
+
"receiptPath": str(path),
|
|
4401
|
+
}
|
|
4402
|
+
project = evaluated["project"]
|
|
4403
|
+
ownership = evaluated["ownership"]
|
|
4404
|
+
if path.is_file():
|
|
4405
|
+
existing = validate_existing_range_adoption(change_dir)
|
|
4406
|
+
existing_receipt = (
|
|
4407
|
+
existing.get("receipt")
|
|
4408
|
+
if isinstance(existing.get("receipt"), dict)
|
|
4409
|
+
else {}
|
|
4410
|
+
)
|
|
4411
|
+
if (
|
|
4412
|
+
existing.get("ok")
|
|
4413
|
+
and existing_receipt.get("baseCommit") == evaluated["baseCommit"]
|
|
4414
|
+
and existing_receipt.get("tipCommit") == evaluated["tipCommit"]
|
|
4415
|
+
and existing_receipt.get("reason") == reason
|
|
4416
|
+
):
|
|
4417
|
+
return {
|
|
4418
|
+
"ok": True,
|
|
4419
|
+
"action": "adopt-existing-range",
|
|
4420
|
+
"reasonCode": "EXISTING_RANGE_ADOPTED",
|
|
4421
|
+
"idempotent": True,
|
|
4422
|
+
"receiptPath": str(path),
|
|
4423
|
+
"receipt": existing_receipt,
|
|
4424
|
+
}
|
|
4425
|
+
return {
|
|
4426
|
+
"ok": False,
|
|
4427
|
+
"action": "adopt-existing-range",
|
|
4428
|
+
"reasonCode": "EXISTING_RANGE_RECEIPT_CONFLICT",
|
|
4429
|
+
"message": "已有不同的既有范围认领收据,拒绝覆盖。",
|
|
4430
|
+
"receiptPath": str(path),
|
|
4431
|
+
}
|
|
4432
|
+
receipt = {
|
|
4433
|
+
"schemaVersion": 1,
|
|
4434
|
+
"action": "adopt-existing-range",
|
|
4435
|
+
"changeName": change_dir.name,
|
|
4436
|
+
"repositoryId": hp.repository_identity(project),
|
|
4437
|
+
"baseCommit": evaluated["baseCommit"],
|
|
4438
|
+
"tipCommit": evaluated["tipCommit"],
|
|
4439
|
+
"diffHash": ownership["diffHash"],
|
|
4440
|
+
"ownershipHash": ownership["ownershipHash"],
|
|
4441
|
+
"files": list(ownership.get("files") or []),
|
|
4442
|
+
"reason": reason,
|
|
4443
|
+
"confirmedByUser": True,
|
|
4444
|
+
"recordedAt": now_iso(),
|
|
4445
|
+
}
|
|
4446
|
+
receipt["receiptId"] = _archive_range_receipt_id(receipt)
|
|
4447
|
+
write_json(path, receipt)
|
|
4448
|
+
return {
|
|
4449
|
+
"ok": True,
|
|
4450
|
+
"action": "adopt-existing-range",
|
|
4451
|
+
"reasonCode": "EXISTING_RANGE_ADOPTED",
|
|
4452
|
+
"idempotent": False,
|
|
4453
|
+
"receiptPath": str(path),
|
|
4454
|
+
"receipt": receipt,
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
|
|
3896
4458
|
def _base_from_state_snapshot(change_dir: Path) -> str:
|
|
3897
4459
|
"""Read archive-boundary base from meta/state-snapshot.json when present."""
|
|
3898
4460
|
for relative in (
|
|
@@ -3908,6 +4470,9 @@ def _base_from_state_snapshot(change_dir: Path) -> str:
|
|
|
3908
4470
|
continue
|
|
3909
4471
|
if not isinstance(payload, dict):
|
|
3910
4472
|
continue
|
|
4473
|
+
change_base = str(payload.get("changeBase") or "").strip()
|
|
4474
|
+
if change_base and change_base != NOT_AVAILABLE:
|
|
4475
|
+
return change_base
|
|
3911
4476
|
git_info = payload.get("git")
|
|
3912
4477
|
if isinstance(git_info, dict):
|
|
3913
4478
|
base = str(git_info.get("base") or git_info.get("baseCommit") or "").strip()
|
|
@@ -3919,6 +4484,27 @@ def _base_from_state_snapshot(change_dir: Path) -> str:
|
|
|
3919
4484
|
return ""
|
|
3920
4485
|
|
|
3921
4486
|
|
|
4487
|
+
def _immutable_change_base_from_snapshot(change_dir: Path) -> str:
|
|
4488
|
+
for root in archive_read_roots(change_dir):
|
|
4489
|
+
for relative in (
|
|
4490
|
+
Path("meta") / "state-snapshot.json",
|
|
4491
|
+
Path("state-snapshot.json"),
|
|
4492
|
+
):
|
|
4493
|
+
path = root / relative
|
|
4494
|
+
if not path.is_file():
|
|
4495
|
+
continue
|
|
4496
|
+
try:
|
|
4497
|
+
payload = read_json(path)
|
|
4498
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
4499
|
+
continue
|
|
4500
|
+
if not isinstance(payload, dict):
|
|
4501
|
+
continue
|
|
4502
|
+
value = str(payload.get("changeBase") or "").strip()
|
|
4503
|
+
if value and value != NOT_AVAILABLE:
|
|
4504
|
+
return value
|
|
4505
|
+
return ""
|
|
4506
|
+
|
|
4507
|
+
|
|
3922
4508
|
def _resolve_base_commit(
|
|
3923
4509
|
ledger: dict[str, Any] | None,
|
|
3924
4510
|
change_dir: Path,
|
|
@@ -3936,8 +4522,22 @@ def _resolve_base_commit(
|
|
|
3936
4522
|
|
|
3937
4523
|
Latest merge phase context must not override the full change boundary.
|
|
3938
4524
|
"""
|
|
4525
|
+
def stable_base(value: Any) -> str:
|
|
4526
|
+
candidate = str(value or "").strip()
|
|
4527
|
+
if candidate.upper() in {"HEAD", "FETCH_HEAD", "ORIG_HEAD"}:
|
|
4528
|
+
return ""
|
|
4529
|
+
if candidate.startswith(("refs/", "@{")):
|
|
4530
|
+
return ""
|
|
4531
|
+
return candidate
|
|
4532
|
+
|
|
4533
|
+
adoption = validate_existing_range_adoption(change_dir)
|
|
4534
|
+
if adoption.get("ok") and adoption.get("present"):
|
|
4535
|
+
adopted_base = stable_base(adoption.get("baseCommit"))
|
|
4536
|
+
if adopted_base:
|
|
4537
|
+
return adopted_base
|
|
4538
|
+
|
|
3939
4539
|
if ledger:
|
|
3940
|
-
base =
|
|
4540
|
+
base = stable_base(ledger.get("baseCommit"))
|
|
3941
4541
|
if base and base != NOT_AVAILABLE:
|
|
3942
4542
|
return base
|
|
3943
4543
|
|
|
@@ -3959,7 +4559,7 @@ def _resolve_base_commit(
|
|
|
3959
4559
|
continue
|
|
3960
4560
|
if not isinstance(payload, dict):
|
|
3961
4561
|
continue
|
|
3962
|
-
base =
|
|
4562
|
+
base = stable_base(payload.get("baseCommit"))
|
|
3963
4563
|
if base and base != NOT_AVAILABLE:
|
|
3964
4564
|
return base
|
|
3965
4565
|
|
|
@@ -3996,9 +4596,18 @@ def _final_commit_from_sources(
|
|
|
3996
4596
|
if str(event.get("phase") or "").lower() not in {"submit", "merge", "archive"}:
|
|
3997
4597
|
continue
|
|
3998
4598
|
text = " ".join(str(event.get(key) or "") for key in ("note", "message", "command"))
|
|
3999
|
-
match
|
|
4000
|
-
|
|
4001
|
-
|
|
4599
|
+
for match in commit_pattern.finditer(text):
|
|
4600
|
+
candidate = match.group(0)
|
|
4601
|
+
code, resolved, _ = git_run(
|
|
4602
|
+
project,
|
|
4603
|
+
"rev-parse",
|
|
4604
|
+
"--verify",
|
|
4605
|
+
f"{candidate}^{{commit}}",
|
|
4606
|
+
)
|
|
4607
|
+
resolved_lines = str(resolved or "").splitlines()
|
|
4608
|
+
resolved_hash = resolved_lines[0].strip() if resolved_lines else ""
|
|
4609
|
+
if code == 0 and re.fullmatch(r"[0-9a-f]{40}", resolved_hash, re.IGNORECASE):
|
|
4610
|
+
return resolved_hash
|
|
4002
4611
|
if existing and existing.get("finalCommit"):
|
|
4003
4612
|
return str(existing["finalCommit"])
|
|
4004
4613
|
code, head, _ = git_run(project, "rev-parse", "HEAD")
|
|
@@ -4669,618 +5278,6 @@ def collect_summary_data(
|
|
|
4669
5278
|
|
|
4670
5279
|
|
|
4671
5280
|
# ---------------------------------------------------------------------------
|
|
4672
|
-
# render
|
|
4673
|
-
# ---------------------------------------------------------------------------
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
def resolve_node_path(project_root: Path) -> str | None:
|
|
4677
|
-
profile = project_root / ".harness" / "config" / "build-profile.json"
|
|
4678
|
-
if profile.is_file():
|
|
4679
|
-
try:
|
|
4680
|
-
data = read_json(profile)
|
|
4681
|
-
node = (data.get("toolPaths") or {}).get("node")
|
|
4682
|
-
if node and Path(str(node)).exists():
|
|
4683
|
-
return str(node)
|
|
4684
|
-
except (OSError, json.JSONDecodeError, TypeError):
|
|
4685
|
-
pass
|
|
4686
|
-
return shutil.which("node")
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
def _render_legacy_fallback_html(summary: dict[str, Any]) -> str:
|
|
4690
|
-
"""Render escaped, deterministic HTML with every validate-required fact.
|
|
4691
|
-
|
|
4692
|
-
Used when the Node renderer is unavailable or fails. No timestamps / random
|
|
4693
|
-
data; all dynamic values are HTML-escaped via _html_escape.
|
|
4694
|
-
"""
|
|
4695
|
-
def esc(v: Any) -> str:
|
|
4696
|
-
return _html_escape("" if v is None else str(v))
|
|
4697
|
-
|
|
4698
|
-
parts: list[str] = [
|
|
4699
|
-
"<!DOCTYPE html>",
|
|
4700
|
-
'<html lang="zh-CN"><head><meta charset="utf-8">',
|
|
4701
|
-
"<title>harness final-summary (python fallback)</title>",
|
|
4702
|
-
"</head><body>",
|
|
4703
|
-
"<h1>变更最终报告(Python fallback 渲染)</h1>",
|
|
4704
|
-
f'<h2 id="changeName">{esc(summary.get("changeName"))}</h2>',
|
|
4705
|
-
f'<p><strong>finalStatus</strong>: '
|
|
4706
|
-
f'<span id="finalStatus">{esc(summary.get("finalStatus"))}</span></p>',
|
|
4707
|
-
f'<div id="finalStatusReasons"><strong>finalStatusReasons</strong><ul>',
|
|
4708
|
-
]
|
|
4709
|
-
for reason in summary.get("finalStatusReasons") or []:
|
|
4710
|
-
parts.append(f"<li>{esc(reason)}</li>")
|
|
4711
|
-
parts.append("</ul></div>")
|
|
4712
|
-
if summary.get("riskTier"):
|
|
4713
|
-
parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
|
|
4714
|
-
parts.append("<h3>Current Outcome</h3>")
|
|
4715
|
-
parts.append(
|
|
4716
|
-
f"<p>finalStatus={esc(summary.get('finalStatus'))}</p>"
|
|
4717
|
-
)
|
|
4718
|
-
|
|
4719
|
-
candidate = (
|
|
4720
|
-
summary.get("candidateVerification")
|
|
4721
|
-
if isinstance(summary.get("candidateVerification"), dict)
|
|
4722
|
-
else {}
|
|
4723
|
-
)
|
|
4724
|
-
parts.append("<h3>Candidate Claim / Attestation</h3>")
|
|
4725
|
-
parts.append(
|
|
4726
|
-
f"<p>assurance={esc(candidate.get('assurance'))} "
|
|
4727
|
-
f"code={esc(candidate.get('code'))} "
|
|
4728
|
-
f"provider={esc(candidate.get('provider'))}</p>"
|
|
4729
|
-
)
|
|
4730
|
-
|
|
4731
|
-
integrity = (
|
|
4732
|
-
summary.get("archiveIntegrity")
|
|
4733
|
-
if isinstance(summary.get("archiveIntegrity"), dict)
|
|
4734
|
-
else {}
|
|
4735
|
-
)
|
|
4736
|
-
parts.append("<h3>Archive Integrity</h3>")
|
|
4737
|
-
parts.append(
|
|
4738
|
-
f"<p>ok={esc(integrity.get('ok'))} "
|
|
4739
|
-
f"code={esc(integrity.get('code'))} "
|
|
4740
|
-
f"checksumStatus={esc(integrity.get('checksumStatus'))}</p>"
|
|
4741
|
-
)
|
|
4742
|
-
|
|
4743
|
-
decision = (
|
|
4744
|
-
summary.get("releaseDecision")
|
|
4745
|
-
if isinstance(summary.get("releaseDecision"), dict)
|
|
4746
|
-
else {}
|
|
4747
|
-
)
|
|
4748
|
-
parts.append("<h3>Release Eligibility</h3>")
|
|
4749
|
-
parts.append(
|
|
4750
|
-
f"<p>releaseEligible={esc(decision.get('releaseEligible'))} "
|
|
4751
|
-
f"code={esc(decision.get('code'))}</p><ul>"
|
|
4752
|
-
)
|
|
4753
|
-
checks = decision.get("checks")
|
|
4754
|
-
checks = checks if isinstance(checks, dict) else {}
|
|
4755
|
-
for name, check in checks.items():
|
|
4756
|
-
check = check if isinstance(check, dict) else {}
|
|
4757
|
-
parts.append(
|
|
4758
|
-
f"<li>{esc(name)}: ok={esc(check.get('ok'))} "
|
|
4759
|
-
f"code={esc(check.get('code'))}</li>"
|
|
4760
|
-
)
|
|
4761
|
-
parts.append("</ul>")
|
|
4762
|
-
|
|
4763
|
-
timing = summary.get("timing") if isinstance(summary.get("timing"), dict) else {}
|
|
4764
|
-
if timing:
|
|
4765
|
-
parts.append("<h3>Timing</h3>")
|
|
4766
|
-
parts.append(
|
|
4767
|
-
"<p id=\"timingColumns\">"
|
|
4768
|
-
f"stageActiveExecution={esc(timing.get('stageActiveExecutionMs'))} · "
|
|
4769
|
-
f"stageWallClockSpan={esc(timing.get('stageWallClockSpanMs'))} · "
|
|
4770
|
-
f"workflowWallClock={esc(timing.get('workflowWallClockMs'))}"
|
|
4771
|
-
"</p>"
|
|
4772
|
-
)
|
|
4773
|
-
parts.append(
|
|
4774
|
-
"<p id=\"timingConservation\">"
|
|
4775
|
-
f"attributedStageUnionMs={esc(timing.get('attributedStageUnionMs'))} · "
|
|
4776
|
-
f"externalWaitMs={esc(timing.get('externalWaitMs'))} · "
|
|
4777
|
-
f"pausedMs={esc(timing.get('pausedMs'))} · "
|
|
4778
|
-
"agentOrToolUnattributedMs="
|
|
4779
|
-
f"{esc(timing.get('agentOrToolUnattributedMs'))} · "
|
|
4780
|
-
f"conservationDeltaMs={esc(timing.get('conservationDeltaMs'))}"
|
|
4781
|
-
"</p>"
|
|
4782
|
-
)
|
|
4783
|
-
parts.append(
|
|
4784
|
-
f"<p><strong>reportCutoffAt</strong>: "
|
|
4785
|
-
f"<span id=\"reportCutoffAt\">{esc(timing.get('reportCutoffAt'))}</span></p>"
|
|
4786
|
-
)
|
|
4787
|
-
parts.append(
|
|
4788
|
-
"<p><small>durations.totalMinutes is active-only; "
|
|
4789
|
-
"do not treat it as workflow wall clock.</small></p>"
|
|
4790
|
-
)
|
|
4791
|
-
parts.append("<h3>History Quality</h3><ul>")
|
|
4792
|
-
attempts = timing.get("attempts")
|
|
4793
|
-
attempts = attempts if isinstance(attempts, list) else []
|
|
4794
|
-
for attempt in attempts:
|
|
4795
|
-
attempt = attempt if isinstance(attempt, dict) else {}
|
|
4796
|
-
parts.append(
|
|
4797
|
-
f"<li>{esc(attempt.get('phase'))}: "
|
|
4798
|
-
f"{esc(attempt.get('terminalStatus') or attempt.get('status'))} "
|
|
4799
|
-
f"durationMs={esc(attempt.get('durationMs'))}</li>"
|
|
4800
|
-
)
|
|
4801
|
-
if not attempts:
|
|
4802
|
-
parts.append("<li>no typed attempts recorded</li>")
|
|
4803
|
-
parts.append("</ul>")
|
|
4804
|
-
|
|
4805
|
-
remote_cost = (
|
|
4806
|
-
summary.get("remoteCost")
|
|
4807
|
-
if isinstance(summary.get("remoteCost"), dict)
|
|
4808
|
-
else {}
|
|
4809
|
-
)
|
|
4810
|
-
remote_totals = remote_cost.get("totals")
|
|
4811
|
-
remote_totals = remote_totals if isinstance(remote_totals, dict) else {}
|
|
4812
|
-
parts.append("<h3>Remote Cost</h3>")
|
|
4813
|
-
parts.append(
|
|
4814
|
-
f"<p>runnerMinutes={esc(remote_totals.get('runnerMinutes'))} "
|
|
4815
|
-
f"queueWaitMs={esc(remote_totals.get('queueWaitMs'))} "
|
|
4816
|
-
f"artifactBytes={esc(remote_totals.get('artifactBytes'))} "
|
|
4817
|
-
f"duplicateRunCount={esc(remote_totals.get('duplicateRunCount'))}</p>"
|
|
4818
|
-
)
|
|
4819
|
-
|
|
4820
|
-
storage = (
|
|
4821
|
-
summary.get("artifactStorage")
|
|
4822
|
-
if isinstance(summary.get("artifactStorage"), dict)
|
|
4823
|
-
else {}
|
|
4824
|
-
)
|
|
4825
|
-
parts.append("<h3>Artifact Storage</h3>")
|
|
4826
|
-
parts.append(
|
|
4827
|
-
f"<p>artifactCount={esc(storage.get('artifactCount'))} "
|
|
4828
|
-
f"bytesAdded={esc(storage.get('bytesAdded'))} "
|
|
4829
|
-
f"bytesReused={esc(storage.get('bytesReused'))} "
|
|
4830
|
-
f"bytesPruned={esc(storage.get('bytesPruned'))}</p>"
|
|
4831
|
-
)
|
|
4832
|
-
|
|
4833
|
-
projection = (
|
|
4834
|
-
summary.get("projection")
|
|
4835
|
-
if isinstance(summary.get("projection"), dict)
|
|
4836
|
-
else {}
|
|
4837
|
-
)
|
|
4838
|
-
parts.append("<h3>Projection / Fallback</h3>")
|
|
4839
|
-
parts.append(
|
|
4840
|
-
f"<p>mode={esc(projection.get('mode'))} "
|
|
4841
|
-
f"code={esc(projection.get('code'))} "
|
|
4842
|
-
f"remediation={esc(projection.get('remediation'))}</p>"
|
|
4843
|
-
)
|
|
4844
|
-
|
|
4845
|
-
if summary.get("productCommit") or summary.get("productTreeHash"):
|
|
4846
|
-
parts.append("<h3>Identity</h3>")
|
|
4847
|
-
parts.append(
|
|
4848
|
-
f"<p>productCommit={esc(summary.get('productCommit'))} "
|
|
4849
|
-
f"productTreeHash={esc(summary.get('productTreeHash'))} "
|
|
4850
|
-
f"archiveCommit={esc(summary.get('archiveCommit'))}</p>"
|
|
4851
|
-
)
|
|
4852
|
-
|
|
4853
|
-
pipeline = summary.get("reportPipeline") or {}
|
|
4854
|
-
cmds = pipeline.get("commands") or []
|
|
4855
|
-
parts.append("<h3>Commands</h3><ul>")
|
|
4856
|
-
for c in cmds:
|
|
4857
|
-
parts.append(
|
|
4858
|
-
f"<li><code>{esc(c.get('command'))}</code> "
|
|
4859
|
-
f"exitCode={esc(c.get('exit_code'))}</li>"
|
|
4860
|
-
)
|
|
4861
|
-
parts.append("</ul>")
|
|
4862
|
-
|
|
4863
|
-
ver = summary.get("verification") or {}
|
|
4864
|
-
unit = ver.get("unitTests") or {}
|
|
4865
|
-
api = ver.get("apiTests") or {}
|
|
4866
|
-
browser = ver.get("browserE2E") or {}
|
|
4867
|
-
parts.append("<h3>Verification</h3>")
|
|
4868
|
-
parts.append(
|
|
4869
|
-
"<p>unitTests: run={run} failures={failures} errors={errors} "
|
|
4870
|
-
"skipped={skipped} passRate={passRate} status={status}</p>".format(
|
|
4871
|
-
run=esc(unit.get("run")),
|
|
4872
|
-
failures=esc(unit.get("failures")),
|
|
4873
|
-
errors=esc(unit.get("errors")),
|
|
4874
|
-
skipped=esc(unit.get("skipped")),
|
|
4875
|
-
passRate=esc(unit.get("passRate")),
|
|
4876
|
-
status=esc(unit.get("status")),
|
|
4877
|
-
)
|
|
4878
|
-
)
|
|
4879
|
-
parts.append(
|
|
4880
|
-
"<p>apiTests: status={status} total={total} passed={passed} "
|
|
4881
|
-
"failed={failed} blocked={blocked}</p>".format(
|
|
4882
|
-
status=esc(api.get("status")),
|
|
4883
|
-
total=esc(api.get("total")),
|
|
4884
|
-
passed=esc(api.get("passed")),
|
|
4885
|
-
failed=esc(api.get("failed")),
|
|
4886
|
-
blocked=esc(api.get("blocked")),
|
|
4887
|
-
)
|
|
4888
|
-
)
|
|
4889
|
-
parts.append(
|
|
4890
|
-
"<p>browserE2E: status={status} total={total} passed={passed} "
|
|
4891
|
-
"failed={failed} skipped={skipped} retries={retries}</p>".format(
|
|
4892
|
-
status=esc(browser.get("status")),
|
|
4893
|
-
total=esc(browser.get("total")),
|
|
4894
|
-
passed=esc(browser.get("passed")),
|
|
4895
|
-
failed=esc(browser.get("failed")),
|
|
4896
|
-
skipped=esc(browser.get("skipped")),
|
|
4897
|
-
retries=esc(browser.get("retries")),
|
|
4898
|
-
)
|
|
4899
|
-
)
|
|
4900
|
-
parts.append(f"<p>dbCompatibility: {esc(ver.get('dbCompatibility'))}</p>")
|
|
4901
|
-
|
|
4902
|
-
parts.append("<h3>Changed Files</h3><ul>")
|
|
4903
|
-
for f in summary.get("changedFiles") or []:
|
|
4904
|
-
parts.append(
|
|
4905
|
-
f"<li>{esc(f.get('path'))} +{esc(f.get('insertions'))} "
|
|
4906
|
-
f"-{esc(f.get('deletions'))}</li>"
|
|
4907
|
-
)
|
|
4908
|
-
parts.append("</ul>")
|
|
4909
|
-
|
|
4910
|
-
am = summary.get("archiveManifest") or {}
|
|
4911
|
-
parts.append("<h3>Archive Manifest</h3>")
|
|
4912
|
-
parts.append(
|
|
4913
|
-
"<p>movedFiles={moved} generatedFiles={gen} totalArchiveFiles={total} "
|
|
4914
|
-
"checksumStatus={cs}</p>".format(
|
|
4915
|
-
moved=esc(am.get("movedFiles")),
|
|
4916
|
-
gen=esc(am.get("generatedFiles")),
|
|
4917
|
-
total=esc(am.get("totalArchiveFiles")),
|
|
4918
|
-
cs=esc(am.get("checksumStatus")),
|
|
4919
|
-
)
|
|
4920
|
-
)
|
|
4921
|
-
|
|
4922
|
-
def _list_section(title: str, items: Any) -> None:
|
|
4923
|
-
parts.append(f"<h3>{esc(title)}</h3><ul>")
|
|
4924
|
-
for it in items or []:
|
|
4925
|
-
parts.append(f"<li>{esc(it)}</li>")
|
|
4926
|
-
parts.append("</ul>")
|
|
4927
|
-
|
|
4928
|
-
_list_section("Known Risks", summary.get("knownRisks"))
|
|
4929
|
-
_list_section("Manual Actions", summary.get("manualActions"))
|
|
4930
|
-
_list_section("Maintenance Notes", summary.get("maintenanceNotes"))
|
|
4931
|
-
|
|
4932
|
-
parts.append("</body></html>")
|
|
4933
|
-
return "\n".join(parts) + "\n"
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
4937
|
-
"""Render the same executive information architecture without Node."""
|
|
4938
|
-
|
|
4939
|
-
def esc(value: Any) -> str:
|
|
4940
|
-
return _html_escape("" if value is None else str(value))
|
|
4941
|
-
|
|
4942
|
-
def rec(value: Any) -> dict[str, Any]:
|
|
4943
|
-
return value if isinstance(value, dict) else {}
|
|
4944
|
-
|
|
4945
|
-
def seq(value: Any) -> list[Any]:
|
|
4946
|
-
return value if isinstance(value, list) else []
|
|
4947
|
-
|
|
4948
|
-
normalized = rec(summary.get("normalizedReport"))
|
|
4949
|
-
outcomes = rec(normalized.get("outcomes"))
|
|
4950
|
-
current = rec(outcomes.get("current")) or {
|
|
4951
|
-
"status": summary.get("finalStatus"),
|
|
4952
|
-
"reasons": seq(summary.get("finalStatusReasons")),
|
|
4953
|
-
"stages": rec(summary.get("stageStatus")),
|
|
4954
|
-
"knownRisks": seq(summary.get("knownRisks")),
|
|
4955
|
-
}
|
|
4956
|
-
release = rec(outcomes.get("release")) or {
|
|
4957
|
-
"intent": summary.get("archiveIntent"),
|
|
4958
|
-
"decision": "NOT_REQUESTED"
|
|
4959
|
-
if summary.get("archiveIntent") == "record-only"
|
|
4960
|
-
else rec(summary.get("releaseDecision")).get("code"),
|
|
4961
|
-
"eligible": rec(summary.get("releaseDecision")).get("releaseEligible"),
|
|
4962
|
-
}
|
|
4963
|
-
identity = rec(normalized.get("identity")) or rec(summary.get("changeIdentity"))
|
|
4964
|
-
verification = rec(normalized.get("verification")) or rec(
|
|
4965
|
-
summary.get("verification")
|
|
4966
|
-
)
|
|
4967
|
-
timing = rec(normalized.get("timing")) or rec(summary.get("timing"))
|
|
4968
|
-
measurements = rec(normalized.get("measurements"))
|
|
4969
|
-
record_only = (
|
|
4970
|
-
release.get("intent") == "record-only"
|
|
4971
|
-
or summary.get("archiveIntent") == "record-only"
|
|
4972
|
-
)
|
|
4973
|
-
product_commit = (
|
|
4974
|
-
identity.get("productCommit")
|
|
4975
|
-
or summary.get("productCommit")
|
|
4976
|
-
or summary.get("finalCommit")
|
|
4977
|
-
or "N/A"
|
|
4978
|
-
)
|
|
4979
|
-
labels = [
|
|
4980
|
-
("后端", ("unitTests", "dbCompatibility")),
|
|
4981
|
-
("Geo", ("geo",)),
|
|
4982
|
-
("前端", ("frontend",)),
|
|
4983
|
-
("浏览器", ("browserE2E",)),
|
|
4984
|
-
("API", ("apiTests",)),
|
|
4985
|
-
]
|
|
4986
|
-
status_labels = {
|
|
4987
|
-
"OK": "通过",
|
|
4988
|
-
"PASS": "通过",
|
|
4989
|
-
"PASSED": "通过",
|
|
4990
|
-
"CONDITIONAL_OK": "有条件通过",
|
|
4991
|
-
"WARN": "警告",
|
|
4992
|
-
"ADVISORY": "建议",
|
|
4993
|
-
"FAIL": "失败",
|
|
4994
|
-
"FAILED": "失败",
|
|
4995
|
-
"ERROR": "错误",
|
|
4996
|
-
"BLOCKED": "阻塞",
|
|
4997
|
-
"NOT_RUN": "未运行",
|
|
4998
|
-
"SKIPPED": "已跳过",
|
|
4999
|
-
"NOT_APPLICABLE": "不适用",
|
|
5000
|
-
"UNKNOWN": "未知",
|
|
5001
|
-
}
|
|
5002
|
-
|
|
5003
|
-
def status_label(value: Any) -> str:
|
|
5004
|
-
status = str(value or "UNKNOWN").upper()
|
|
5005
|
-
return status_labels.get(status, status)
|
|
5006
|
-
|
|
5007
|
-
def verification_status(value: Any) -> str:
|
|
5008
|
-
if isinstance(value, str):
|
|
5009
|
-
return value
|
|
5010
|
-
item = rec(value)
|
|
5011
|
-
if item.get("status"):
|
|
5012
|
-
return str(item["status"])
|
|
5013
|
-
if int(item.get("failures") or 0) + int(item.get("errors") or 0):
|
|
5014
|
-
return "FAIL"
|
|
5015
|
-
if int(item.get("run") or item.get("total") or 0):
|
|
5016
|
-
return "OK"
|
|
5017
|
-
return "NOT_RUN"
|
|
5018
|
-
|
|
5019
|
-
def describe(value: Any) -> str:
|
|
5020
|
-
if isinstance(value, str):
|
|
5021
|
-
return value
|
|
5022
|
-
item = rec(value)
|
|
5023
|
-
return str(
|
|
5024
|
-
item.get("title")
|
|
5025
|
-
or item.get("message")
|
|
5026
|
-
or item.get("summary")
|
|
5027
|
-
or item.get("action")
|
|
5028
|
-
or item.get("remediation")
|
|
5029
|
-
or item
|
|
5030
|
-
)
|
|
5031
|
-
|
|
5032
|
-
def measurement_label(value: Any) -> str:
|
|
5033
|
-
item = rec(value)
|
|
5034
|
-
if item.get("state") in {"unknown", "not_applicable"}:
|
|
5035
|
-
return "N/A"
|
|
5036
|
-
if item.get("state") == "zero":
|
|
5037
|
-
return "0"
|
|
5038
|
-
if item.get("state") == "known":
|
|
5039
|
-
return str(item.get("value"))
|
|
5040
|
-
return "N/A" if value is None or value == "" else str(value)
|
|
5041
|
-
|
|
5042
|
-
risks = (
|
|
5043
|
-
seq(current.get("findings"))
|
|
5044
|
-
or seq(current.get("knownRisks"))
|
|
5045
|
-
or seq(summary.get("knownRisks"))
|
|
5046
|
-
)
|
|
5047
|
-
actions = seq(summary.get("manualActions"))
|
|
5048
|
-
groups: list[str] = []
|
|
5049
|
-
passed = 0
|
|
5050
|
-
for label, keys in labels:
|
|
5051
|
-
statuses = [
|
|
5052
|
-
verification_status(verification[key])
|
|
5053
|
-
for key in keys
|
|
5054
|
-
if key in verification
|
|
5055
|
-
]
|
|
5056
|
-
status = (
|
|
5057
|
-
"FAIL"
|
|
5058
|
-
if any(value in {"FAIL", "ERROR", "BLOCKED"} for value in statuses)
|
|
5059
|
-
else "WARN"
|
|
5060
|
-
if any("WARN" in value or "CONDITIONAL" in value for value in statuses)
|
|
5061
|
-
else "NOT_APPLICABLE"
|
|
5062
|
-
if statuses and all(value == "NOT_APPLICABLE" for value in statuses)
|
|
5063
|
-
else "OK"
|
|
5064
|
-
if statuses
|
|
5065
|
-
and all(
|
|
5066
|
-
value in {"OK", "PASS", "PASSED", "NOT_APPLICABLE"}
|
|
5067
|
-
for value in statuses
|
|
5068
|
-
)
|
|
5069
|
-
else "NOT_RUN"
|
|
5070
|
-
)
|
|
5071
|
-
if status == "OK":
|
|
5072
|
-
passed += 1
|
|
5073
|
-
details = " · ".join(
|
|
5074
|
-
str(rec(verification.get(key)).get("passRate") or status)
|
|
5075
|
-
for key in keys
|
|
5076
|
-
if key in verification
|
|
5077
|
-
) or "未配置该组验证"
|
|
5078
|
-
raw_details = " · ".join(
|
|
5079
|
-
f"{key} · status={verification_status(verification[key])}"
|
|
5080
|
-
+ (
|
|
5081
|
-
f" · failed={rec(verification[key]).get('failed')}"
|
|
5082
|
-
if rec(verification[key]).get("failed") is not None
|
|
5083
|
-
else ""
|
|
5084
|
-
)
|
|
5085
|
-
for key in keys
|
|
5086
|
-
if key in verification
|
|
5087
|
-
)
|
|
5088
|
-
groups.append(
|
|
5089
|
-
f'<article class="verify" title="{esc(raw_details)}"><strong>{esc(label)}</strong>'
|
|
5090
|
-
f'<span title="{esc(status)}">{esc(status_label(status))}</span><small>{esc(details)}</small></article>'
|
|
5091
|
-
)
|
|
5092
|
-
|
|
5093
|
-
commands = seq(rec(summary.get("reportPipeline")).get("commands"))
|
|
5094
|
-
command_rows = "".join(
|
|
5095
|
-
f"<tr><td>{esc(item.get('phase') or '-')}</td>"
|
|
5096
|
-
f"<td><code>{esc(item.get('command'))}</code></td>"
|
|
5097
|
-
f"<td>{esc('OK' if int(item.get('exit_code', item.get('exitCode', 1))) == 0 else 'FAIL')}</td></tr>"
|
|
5098
|
-
for item in commands
|
|
5099
|
-
if isinstance(item, dict)
|
|
5100
|
-
) or '<tr><td colspan="3">没有命令证据</td></tr>'
|
|
5101
|
-
files = seq(summary.get("changedFiles"))
|
|
5102
|
-
file_rows = "".join(
|
|
5103
|
-
f"<tr><td><code>{esc(item.get('path') or item.get('file'))}</code></td>"
|
|
5104
|
-
f"<td>+{esc(item.get('insertions') or 0)}</td>"
|
|
5105
|
-
f"<td>-{esc(item.get('deletions') or 0)}</td></tr>"
|
|
5106
|
-
for item in files
|
|
5107
|
-
if isinstance(item, dict)
|
|
5108
|
-
) or '<tr><td colspan="3">没有变更文件证据</td></tr>'
|
|
5109
|
-
risk_rows = "".join(f"<li>{esc(describe(item))}</li>" for item in risks) or "<li>当前没有未处置风险</li>"
|
|
5110
|
-
action_rows = "".join(f"<li>{esc(describe(item))}</li>" for item in actions) or "<li>无需人工后续动作</li>"
|
|
5111
|
-
release_card = (
|
|
5112
|
-
""
|
|
5113
|
-
if record_only
|
|
5114
|
-
else "<article class=\"card\"><h2>发布与候选</h2>"
|
|
5115
|
-
f"<p>候选证明 / 发布资格:{esc(release.get('decision') or 'NOT_EVALUATED')}</p></article>"
|
|
5116
|
-
)
|
|
5117
|
-
remote = rec(measurements.get("remoteCost"))
|
|
5118
|
-
storage = rec(measurements.get("artifactStorage"))
|
|
5119
|
-
remote_totals = rec(remote.get("totals"))
|
|
5120
|
-
projection = rec(summary.get("projection"))
|
|
5121
|
-
archive_integrity = rec(summary.get("archiveIntegrity"))
|
|
5122
|
-
durability = rec(summary.get("archiveDurability"))
|
|
5123
|
-
durability_status = str(
|
|
5124
|
-
durability.get("status") or "ARCHIVED_LOCAL_ONLY"
|
|
5125
|
-
)
|
|
5126
|
-
scenario_coverage = rec(summary.get("scenarioCoverage"))
|
|
5127
|
-
unexecuted_scenarios = [
|
|
5128
|
-
str(item) for item in seq(scenario_coverage.get("unexecuted"))
|
|
5129
|
-
]
|
|
5130
|
-
scenario_status = str(
|
|
5131
|
-
scenario_coverage.get("code")
|
|
5132
|
-
or ("SCENARIO_COVERAGE_OK" if scenario_coverage.get("ok") else "NOT_RUN")
|
|
5133
|
-
)
|
|
5134
|
-
total_files = rec(summary.get("archiveManifest")).get("totalArchiveFiles")
|
|
5135
|
-
total_files_text = "" if total_files is None else str(total_files)
|
|
5136
|
-
|
|
5137
|
-
return f"""<!doctype html>
|
|
5138
|
-
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
5139
|
-
<title>Harness 最终报告 · {esc(summary.get("changeName"))}</title>
|
|
5140
|
-
<style>
|
|
5141
|
-
:root{{--bg:#f4f6f8;--card:#fff;--text:#172033;--muted:#667085;--line:#dfe5ec}}
|
|
5142
|
-
@media(prefers-color-scheme:dark){{:root{{--bg:#0b1119;--card:#121a26;--text:#eef3f9;--muted:#9aa8ba;--line:#2a384a}}}}
|
|
5143
|
-
*{{box-sizing:border-box}}html,body{{max-width:100%;overflow-x:hidden}}body{{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif}}main{{width:min(1120px,calc(100% - 32px));margin:24px auto}}.hero,.card,.metric,.verify,details{{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px}}.hero h1{{margin:4px 0}}.metrics,.groups{{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin:10px 0}}.metric strong,.verify strong{{display:block;font-size:17px}}.verify small{{display:block;color:var(--muted)}}.risk{{display:grid;grid-template-columns:1fr 1fr;gap:10px}}.card{{margin:10px 0}}details{{margin:9px 0}}summary{{cursor:pointer;font-weight:700}}details>div{{overflow:auto}}table{{width:100%;border-collapse:collapse}}td,th{{padding:8px;border-bottom:1px solid var(--line);text-align:left}}code{{overflow-wrap:anywhere}}@media(max-width:600px){{main{{width:calc(100% - 18px);margin:9px auto}}.metrics,.groups,.risk{{grid-template-columns:1fr}}}}
|
|
5144
|
-
</style></head><body><main>
|
|
5145
|
-
<section class="hero"><small>HARNESS · 管理结论</small><h1>{esc(summary.get("changeName"))}</h1>
|
|
5146
|
-
<p>{esc(summary.get("businessGoal") or "未记录业务目标")}</p>
|
|
5147
|
-
<strong title="{esc(current.get("status"))}">{esc(status_label(current.get("status")))}</strong>
|
|
5148
|
-
<p id="finalStatusReasons">{esc(" · ".join(str(item) for item in seq(current.get("reasons"))))}</p>
|
|
5149
|
-
{"<p>归档意图:仅记录 · 未请求发布</p>" if record_only else ""}</section>
|
|
5150
|
-
<section class="metrics"><article class="metric"><small>产品提交</small><strong><code>{esc(str(product_commit)[:10])}</code></strong></article>
|
|
5151
|
-
<article class="metric"><small>验证概览</small><strong>{passed}/5 组通过</strong></article>
|
|
5152
|
-
<article class="metric"><small>风险与动作</small><strong>{len(risks)} / {len(actions)}</strong></article>
|
|
5153
|
-
<article class="metric"><small>全流程耗时</small><strong>{esc(timing.get("workflowWallClockMs") or "N/A")}</strong></article>
|
|
5154
|
-
<article class="metric"><small>归档文件</small><strong>{esc(total_files_text or "N/A")}</strong></article></section>
|
|
5155
|
-
<article class="card"><h2>验证概览</h2><div class="groups">{"".join(groups)}</div></article>
|
|
5156
|
-
<article class="card"><h2>场景执行闭环</h2>
|
|
5157
|
-
<p>结构化运行回执:{esc(scenario_status)}</p>
|
|
5158
|
-
<p>未执行 / 未通过:{esc(", ".join(unexecuted_scenarios) or "无")}</p></article>
|
|
5159
|
-
<article class="card"><h2>风险与动作</h2><div class="risk"><section><h3>当前风险</h3><ul>{risk_rows}</ul></section><section><h3>人工动作</h3><ul>{action_rows}</ul></section></div></article>
|
|
5160
|
-
{release_card}
|
|
5161
|
-
<details><summary>技术证据 · 时间守恒</summary><div><p>conservationDeltaMs={esc(timing.get("conservationDeltaMs") if "conservationDeltaMs" in timing else "N/A")}</p>
|
|
5162
|
-
<p>workflowWallClock={esc(timing.get("workflowWallClockMs") if "workflowWallClockMs" in timing else "N/A")}</p>
|
|
5163
|
-
<p>stageActiveExecution={esc(timing.get("stageActiveExecutionMs") if "stageActiveExecutionMs" in timing else "N/A")}</p>
|
|
5164
|
-
<p>stageWallClockSpan={esc(timing.get("stageWallClockSpanMs") if "stageWallClockSpanMs" in timing else "N/A")}</p>
|
|
5165
|
-
<p>reportCutoffAt={esc(timing.get("reportCutoffAt") or "N/A")}</p>
|
|
5166
|
-
<p>远端 runner 成本:{esc(measurement_label(remote_totals.get("runnerMinutes") or remote.get("runnerMinutes")))}</p>
|
|
5167
|
-
<p>新增制品字节:{esc(measurement_label(storage.get("bytesAdded")))}</p></div></details>
|
|
5168
|
-
<details><summary>技术证据 · 发布与归档治理</summary><div>
|
|
5169
|
-
<p>归档完整性:{esc(archive_integrity.get("code") or rec(summary.get("archiveManifest")).get("checksumStatus") or "N/A")}</p>
|
|
5170
|
-
<p>归档持久性:{esc(durability_status)} · {esc(durability.get("risk") or "已通过持久介质回读校验")}</p>
|
|
5171
|
-
<p>保留策略:{esc(durability.get("retentionPolicy") or "N/A")}</p>
|
|
5172
|
-
<p>历史质量:未闭合尝试 {esc(timing.get("unclosedAttemptCount") if "unclosedAttemptCount" in timing else "N/A")}</p>
|
|
5173
|
-
<p>投影状态:{esc(projection.get("code") or projection.get("mode") or "N/A")}</p></div></details>
|
|
5174
|
-
<details><summary>技术证据 · 变更文件</summary><div><table><tbody>{file_rows}</tbody></table></div></details>
|
|
5175
|
-
<details><summary>技术证据 · 命令</summary><div><table><tbody>{command_rows}</tbody></table></div></details>
|
|
5176
|
-
<details><summary>技术元数据</summary><div><p>productCommit=<code>{esc(product_commit)}</code></p>
|
|
5177
|
-
<p>schemaVersion={esc(summary.get("schemaVersion"))}</p></div></details>
|
|
5178
|
-
</main></body></html>
|
|
5179
|
-
"""
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
# P1 slim-files: final-summary.html is a regenerable presentation layer over
|
|
5184
|
-
# summary-data.json. `finalize --no-html` skips local HTML rendering entirely
|
|
5185
|
-
# (a remote platform can render from summary-data.json); render failures are
|
|
5186
|
-
# downgraded to warnings instead of rolling back the archive.
|
|
5187
|
-
RENDER_HTML_ENABLED = True
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
def render_final_summary(
|
|
5191
|
-
change_dir: Path,
|
|
5192
|
-
summary_path: Path,
|
|
5193
|
-
) -> dict[str, Any]:
|
|
5194
|
-
"""Render final-summary.html via Node; fall back to a Python renderer.
|
|
5195
|
-
|
|
5196
|
-
Returns ``{ok, renderer, fallbackReason, out_path}``:
|
|
5197
|
-
- Node success -> renderer="node".
|
|
5198
|
-
- Node unavailable/timeout/non-zero/no-file -> Python fallback; success ->
|
|
5199
|
-
renderer="python-fallback" (fallbackReason carries the node failure cause).
|
|
5200
|
-
- Both fail or produce no file -> ok=False (caller downgrades to warning).
|
|
5201
|
-
- Rendering disabled (--no-html) -> ok=True with renderer="skipped".
|
|
5202
|
-
"""
|
|
5203
|
-
out_path = change_dir / "reports" / "final" / "final-summary.html"
|
|
5204
|
-
if not RENDER_HTML_ENABLED:
|
|
5205
|
-
return {
|
|
5206
|
-
"ok": True,
|
|
5207
|
-
"renderer": "skipped",
|
|
5208
|
-
"fallbackReason": "",
|
|
5209
|
-
"out_path": str(out_path),
|
|
5210
|
-
"skipped": True,
|
|
5211
|
-
}
|
|
5212
|
-
project = find_project_root(change_dir)
|
|
5213
|
-
node = resolve_node_path(project)
|
|
5214
|
-
fallback_reason = ""
|
|
5215
|
-
|
|
5216
|
-
if node and RENDER_SCRIPT.is_file():
|
|
5217
|
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
5218
|
-
try:
|
|
5219
|
-
proc = subprocess.run(
|
|
5220
|
-
[
|
|
5221
|
-
node,
|
|
5222
|
-
str(RENDER_SCRIPT),
|
|
5223
|
-
"--summary",
|
|
5224
|
-
str(summary_path),
|
|
5225
|
-
"--out",
|
|
5226
|
-
str(out_path),
|
|
5227
|
-
],
|
|
5228
|
-
capture_output=True,
|
|
5229
|
-
text=True,
|
|
5230
|
-
encoding="utf-8",
|
|
5231
|
-
errors="replace",
|
|
5232
|
-
timeout=60,
|
|
5233
|
-
check=False,
|
|
5234
|
-
)
|
|
5235
|
-
if proc.returncode == 0 and out_path.is_file():
|
|
5236
|
-
return {
|
|
5237
|
-
"ok": True,
|
|
5238
|
-
"renderer": "node",
|
|
5239
|
-
"fallbackReason": "",
|
|
5240
|
-
"out_path": str(out_path),
|
|
5241
|
-
}
|
|
5242
|
-
fallback_reason = (
|
|
5243
|
-
f"node render exit {proc.returncode}: "
|
|
5244
|
-
f"{(proc.stderr or proc.stdout or '')[:200]}"
|
|
5245
|
-
)
|
|
5246
|
-
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
5247
|
-
fallback_reason = f"node render failed: {exc}"
|
|
5248
|
-
else:
|
|
5249
|
-
fallback_reason = (
|
|
5250
|
-
"node unavailable" if not node else f"renderer missing: {RENDER_SCRIPT}"
|
|
5251
|
-
)
|
|
5252
|
-
|
|
5253
|
-
# Python fallback
|
|
5254
|
-
try:
|
|
5255
|
-
summary = read_json(summary_path)
|
|
5256
|
-
html = render_fallback_html(summary)
|
|
5257
|
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
5258
|
-
out_path.write_text(html, encoding="utf-8", newline="\n")
|
|
5259
|
-
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
5260
|
-
return {
|
|
5261
|
-
"ok": False,
|
|
5262
|
-
"renderer": "none",
|
|
5263
|
-
"fallbackReason": f"{fallback_reason}; fallback failed: {exc}",
|
|
5264
|
-
"out_path": str(out_path),
|
|
5265
|
-
}
|
|
5266
|
-
if out_path.is_file():
|
|
5267
|
-
return {
|
|
5268
|
-
"ok": True,
|
|
5269
|
-
"renderer": "python-fallback",
|
|
5270
|
-
"fallbackReason": fallback_reason,
|
|
5271
|
-
"out_path": str(out_path),
|
|
5272
|
-
}
|
|
5273
|
-
return {
|
|
5274
|
-
"ok": False,
|
|
5275
|
-
"renderer": "none",
|
|
5276
|
-
"fallbackReason": f"{fallback_reason}; fallback produced no file",
|
|
5277
|
-
"out_path": str(out_path),
|
|
5278
|
-
}
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
# ---------------------------------------------------------------------------
|
|
5282
|
-
# validate
|
|
5283
|
-
# ---------------------------------------------------------------------------
|
|
5284
5281
|
|
|
5285
5282
|
|
|
5286
5283
|
def _manifest_self_stats(
|
|
@@ -5312,22 +5309,24 @@ def _append_finalize_failure_terminal(
|
|
|
5312
5309
|
*,
|
|
5313
5310
|
operation_id: str,
|
|
5314
5311
|
) -> None:
|
|
5315
|
-
"""
|
|
5312
|
+
"""Record a blocked preparation without manufacturing an archive attempt."""
|
|
5316
5313
|
if not authoritative_change_dir.is_dir():
|
|
5317
5314
|
return
|
|
5318
5315
|
try:
|
|
5319
5316
|
append_event(
|
|
5320
5317
|
authoritative_change_dir,
|
|
5321
5318
|
phase="archive",
|
|
5322
|
-
type_="phase.start",
|
|
5323
|
-
note=
|
|
5319
|
+
type_="phase.prepare.start",
|
|
5320
|
+
note="开始检查归档条件",
|
|
5324
5321
|
)
|
|
5325
5322
|
append_event(
|
|
5326
5323
|
authoritative_change_dir,
|
|
5327
5324
|
phase="archive",
|
|
5328
|
-
type_="phase.end",
|
|
5329
|
-
status="
|
|
5330
|
-
|
|
5325
|
+
type_="phase.prepare.end",
|
|
5326
|
+
status="BLOCKED",
|
|
5327
|
+
code="ARCHIVE_PREPARE_BLOCKED",
|
|
5328
|
+
message=f"归档条件未满足:{message}",
|
|
5329
|
+
note=f"归档操作 {operation_id} 未进入正式执行",
|
|
5331
5330
|
)
|
|
5332
5331
|
except OSError:
|
|
5333
5332
|
pass
|
|
@@ -5501,302 +5500,62 @@ def validate_source_consistency(
|
|
|
5501
5500
|
"message": f"summary {field} does not match frozen source projection",
|
|
5502
5501
|
}
|
|
5503
5502
|
)
|
|
5504
|
-
|
|
5505
|
-
# 4. Manifest structure/checksums and summary semantics.
|
|
5506
|
-
before_path = change_dir / "evidence" / "archive-manifest-before.json"
|
|
5507
|
-
if before_path.is_file():
|
|
5508
|
-
try:
|
|
5509
|
-
manifest = read_json(before_path)
|
|
5510
|
-
entries = manifest.get("files") if isinstance(manifest, dict) else None
|
|
5511
|
-
valid = isinstance(entries, list) and manifest.get("fileCount") == len(entries)
|
|
5512
|
-
seen: set[str] = set()
|
|
5513
|
-
if valid:
|
|
5514
|
-
for entry in entries:
|
|
5515
|
-
rel = str(entry.get("path") or "") if isinstance(entry, dict) else ""
|
|
5516
|
-
digest = str(entry.get("sha256") or "") if isinstance(entry, dict) else ""
|
|
5517
|
-
if (
|
|
5518
|
-
not rel
|
|
5519
|
-
or rel in seen
|
|
5520
|
-
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
5521
|
-
):
|
|
5522
|
-
valid = False
|
|
5523
|
-
break
|
|
5524
|
-
seen.add(rel)
|
|
5525
|
-
target = (change_dir / rel).resolve()
|
|
5526
|
-
if not target.is_relative_to(change_dir.resolve()) or not target.is_file():
|
|
5527
|
-
valid = False
|
|
5528
|
-
break
|
|
5529
|
-
if not _manifest_path_excluded(rel) and sha256_file(target) != digest:
|
|
5530
|
-
valid = False
|
|
5531
|
-
break
|
|
5532
|
-
if not valid:
|
|
5533
|
-
issues.append(
|
|
5534
|
-
{
|
|
5535
|
-
"code": "manifest-invalid",
|
|
5536
|
-
"severity": "error",
|
|
5537
|
-
"message": "archive-manifest-before structure or checksum is invalid",
|
|
5538
|
-
}
|
|
5539
|
-
)
|
|
5540
|
-
except (OSError, json.JSONDecodeError):
|
|
5541
|
-
issues.append(
|
|
5542
|
-
{
|
|
5543
|
-
"code": "manifest-invalid",
|
|
5544
|
-
"severity": "error",
|
|
5545
|
-
"message": "archive-manifest-before is unreadable",
|
|
5546
|
-
}
|
|
5547
|
-
)
|
|
5548
|
-
|
|
5549
|
-
# 5. Artifact paths must resolve to immutable files inside the archive.
|
|
5550
|
-
root = change_dir.resolve()
|
|
5551
|
-
for artifact in summary.get("artifacts") or []:
|
|
5552
|
-
raw = str(artifact.get("path") or "") if isinstance(artifact, dict) else ""
|
|
5553
|
-
candidate = (root / raw).resolve() if raw else root
|
|
5554
|
-
if not raw or not candidate.is_relative_to(root) or not candidate.is_file():
|
|
5555
|
-
issues.append(
|
|
5556
|
-
{
|
|
5557
|
-
"code": "artifact-missing",
|
|
5558
|
-
"severity": "error",
|
|
5559
|
-
"message": f"artifact path is missing or outside archive: {raw}",
|
|
5560
|
-
}
|
|
5561
|
-
)
|
|
5562
|
-
|
|
5563
|
-
errors = [i for i in issues if i.get("severity") == "error"]
|
|
5564
|
-
warnings = [i for i in issues if i.get("severity") != "error"]
|
|
5565
|
-
return {
|
|
5566
|
-
"ok": len(errors) == 0,
|
|
5567
|
-
"issues": issues,
|
|
5568
|
-
"error_count": len(errors),
|
|
5569
|
-
"warning_count": len(warnings),
|
|
5570
|
-
}
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
def validate_summary(
|
|
5574
|
-
summary: dict[str, Any],
|
|
5575
|
-
html_path: Path | None,
|
|
5576
|
-
*,
|
|
5577
|
-
render_skipped: bool = False,
|
|
5578
|
-
) -> dict[str, Any]:
|
|
5579
|
-
"""Validate final-summary covers summary-data key facts (in-process)."""
|
|
5580
|
-
issues: list[dict[str, str]] = []
|
|
5581
|
-
|
|
5582
|
-
change_id = str(summary.get("changeName") or "")
|
|
5583
|
-
html = ""
|
|
5584
|
-
if html_path and html_path.is_file():
|
|
5585
|
-
try:
|
|
5586
|
-
html = html_path.read_text(encoding="utf-8-sig")
|
|
5587
|
-
except OSError as exc:
|
|
5588
|
-
issues.append(
|
|
5589
|
-
{
|
|
5590
|
-
"code": "missing-final-report",
|
|
5591
|
-
"severity": "error",
|
|
5592
|
-
"message": f"cannot read final-summary: {exc}",
|
|
5593
|
-
}
|
|
5594
|
-
)
|
|
5595
|
-
else:
|
|
5596
|
-
# P1 slim-files: final-summary.html 是 summary-data.json 的可再生展示
|
|
5597
|
-
# 投影,不再是硬产物(summary-data + manifest + adequacy 仍 fail-closed)。
|
|
5598
|
-
# 缺 HTML 恒为 warning,不阻断归档收尾;平台端可按 summary-data 渲染。
|
|
5599
|
-
_ = render_skipped # kept for call-site compatibility
|
|
5600
|
-
issues.append(
|
|
5601
|
-
{
|
|
5602
|
-
"code": "missing-final-report",
|
|
5603
|
-
"severity": "warning",
|
|
5604
|
-
"message": "reports/final/final-summary.html not found",
|
|
5605
|
-
}
|
|
5606
|
-
)
|
|
5607
|
-
|
|
5608
|
-
def has_text(needle: str) -> bool:
|
|
5609
|
-
if not needle or needle == NOT_AVAILABLE:
|
|
5610
|
-
return True
|
|
5611
|
-
return needle in html
|
|
5612
|
-
|
|
5613
|
-
# change id
|
|
5614
|
-
if html and change_id and not has_text(change_id):
|
|
5615
|
-
issues.append(
|
|
5616
|
-
{
|
|
5617
|
-
"code": "missing-change-id",
|
|
5618
|
-
"severity": "error",
|
|
5619
|
-
"message": f"final-summary missing change id '{change_id}'",
|
|
5620
|
-
}
|
|
5621
|
-
)
|
|
5622
|
-
|
|
5623
|
-
# key commands — stock render-summary.mjs does not embed reportPipeline.commands.
|
|
5624
|
-
# Require commands to be present in summary-data; HTML absence is warning only.
|
|
5625
|
-
commands = (summary.get("reportPipeline") or {}).get("commands") or []
|
|
5626
|
-
if html:
|
|
5627
|
-
for cmd in commands[:8]:
|
|
5628
|
-
c = str(cmd.get("command") or "").strip()
|
|
5629
|
-
if not c:
|
|
5630
|
-
continue
|
|
5631
|
-
fragment = c if len(c) <= 60 else c[:60]
|
|
5632
|
-
token = c.split()[-1] if c.split() else c
|
|
5633
|
-
in_html = (
|
|
5634
|
-
fragment in html
|
|
5635
|
-
or _html_escape(fragment) in html
|
|
5636
|
-
or (len(token) >= 4 and (token in html or _html_escape(token) in html))
|
|
5637
|
-
)
|
|
5638
|
-
if not in_html:
|
|
5639
|
-
issues.append(
|
|
5640
|
-
{
|
|
5641
|
-
"code": "missing-command",
|
|
5642
|
-
"severity": "warning",
|
|
5643
|
-
"message": (
|
|
5644
|
-
f"final-summary HTML omits command (renderer may not "
|
|
5645
|
-
f"embed commands): {c}"
|
|
5646
|
-
),
|
|
5647
|
-
}
|
|
5648
|
-
)
|
|
5649
|
-
|
|
5650
|
-
# verification
|
|
5651
|
-
ver = summary.get("verification") or {}
|
|
5652
|
-
unit = ver.get("unitTests") or {}
|
|
5653
|
-
api = ver.get("apiTests") or {}
|
|
5654
|
-
browser = ver.get("browserE2E") or {}
|
|
5655
|
-
pass_rate = unit.get("passRate")
|
|
5656
|
-
if pass_rate and pass_rate != NOT_AVAILABLE and str(pass_rate) not in html:
|
|
5657
|
-
issues.append(
|
|
5658
|
-
{
|
|
5659
|
-
"code": "missing-verification",
|
|
5660
|
-
"severity": "warning",
|
|
5661
|
-
"message": f"unitTests.passRate '{pass_rate}' not in final-summary",
|
|
5662
|
-
}
|
|
5663
|
-
)
|
|
5664
|
-
api_status = str(api.get("status") or "")
|
|
5665
|
-
if api_status and api_status not in {"", NOT_AVAILABLE} and api_status not in html:
|
|
5666
|
-
# Renderer shows api status; soft warning
|
|
5667
|
-
if api_status in {"USER_SKIPPED", "BLOCKED_BY_DBA", "NOT_RUN", "PARTIAL"}:
|
|
5668
|
-
issues.append(
|
|
5669
|
-
{
|
|
5670
|
-
"code": "missing-verification",
|
|
5671
|
-
"severity": "warning",
|
|
5672
|
-
"message": f"apiTests.status '{api_status}' not visible in final-summary",
|
|
5673
|
-
}
|
|
5674
|
-
)
|
|
5675
|
-
browser_status = str(browser.get("status") or "")
|
|
5676
|
-
if (
|
|
5677
|
-
browser_status
|
|
5678
|
-
and browser_status not in {"", NOT_AVAILABLE}
|
|
5679
|
-
and browser_status not in html
|
|
5680
|
-
):
|
|
5681
|
-
issues.append(
|
|
5682
|
-
{
|
|
5683
|
-
"code": "missing-verification",
|
|
5684
|
-
"severity": "warning",
|
|
5685
|
-
"message": (
|
|
5686
|
-
f"browserE2E.status '{browser_status}' not visible "
|
|
5687
|
-
"in final-summary"
|
|
5688
|
-
),
|
|
5689
|
-
}
|
|
5690
|
-
)
|
|
5691
|
-
|
|
5692
|
-
# artifacts / summary-data path hints
|
|
5693
|
-
am = summary.get("archiveManifest") or {}
|
|
5694
|
-
total = am.get("totalArchiveFiles")
|
|
5695
|
-
if total is not None and str(total) not in html:
|
|
5696
|
-
issues.append(
|
|
5697
|
-
{
|
|
5698
|
-
"code": "missing-artifact",
|
|
5699
|
-
"severity": "warning",
|
|
5700
|
-
"message": "archiveManifest.totalArchiveFiles not reflected in final-summary",
|
|
5701
|
-
}
|
|
5702
|
-
)
|
|
5703
|
-
|
|
5704
|
-
# risks / manual actions — empty arrays are OK (placeholder)
|
|
5705
|
-
for risk in summary.get("knownRisks") or []:
|
|
5706
|
-
# UT-016/RET-28: canonical projection — structured risk objects
|
|
5707
|
-
# project to their message; str(dict) never matches rendered HTML.
|
|
5708
|
-
if isinstance(risk, dict):
|
|
5709
|
-
text = str(risk.get("message") or risk.get("summary") or "").strip()
|
|
5710
|
-
else:
|
|
5711
|
-
text = str(risk)
|
|
5712
|
-
if text and text not in html and _html_escape(text) not in html:
|
|
5503
|
+
|
|
5504
|
+
# 4. Manifest structure/checksums and summary semantics.
|
|
5505
|
+
before_path = change_dir / "evidence" / "archive-manifest-before.json"
|
|
5506
|
+
if before_path.is_file():
|
|
5507
|
+
try:
|
|
5508
|
+
manifest = read_json(before_path)
|
|
5509
|
+
entries = manifest.get("files") if isinstance(manifest, dict) else None
|
|
5510
|
+
valid = isinstance(entries, list) and manifest.get("fileCount") == len(entries)
|
|
5511
|
+
seen: set[str] = set()
|
|
5512
|
+
if valid:
|
|
5513
|
+
for entry in entries:
|
|
5514
|
+
rel = str(entry.get("path") or "") if isinstance(entry, dict) else ""
|
|
5515
|
+
digest = str(entry.get("sha256") or "") if isinstance(entry, dict) else ""
|
|
5516
|
+
if (
|
|
5517
|
+
not rel
|
|
5518
|
+
or rel in seen
|
|
5519
|
+
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
5520
|
+
):
|
|
5521
|
+
valid = False
|
|
5522
|
+
break
|
|
5523
|
+
seen.add(rel)
|
|
5524
|
+
target = (change_dir / rel).resolve()
|
|
5525
|
+
if not target.is_relative_to(change_dir.resolve()) or not target.is_file():
|
|
5526
|
+
valid = False
|
|
5527
|
+
break
|
|
5528
|
+
if not _manifest_path_excluded(rel) and sha256_file(target) != digest:
|
|
5529
|
+
valid = False
|
|
5530
|
+
break
|
|
5531
|
+
if not valid:
|
|
5713
5532
|
issues.append(
|
|
5714
5533
|
{
|
|
5715
|
-
"code": "
|
|
5716
|
-
"severity": "
|
|
5717
|
-
"message":
|
|
5534
|
+
"code": "manifest-invalid",
|
|
5535
|
+
"severity": "error",
|
|
5536
|
+
"message": "archive-manifest-before structure or checksum is invalid",
|
|
5718
5537
|
}
|
|
5719
5538
|
)
|
|
5720
|
-
|
|
5721
|
-
# status contradiction
|
|
5722
|
-
final_status = str(summary.get("finalStatus") or "")
|
|
5723
|
-
browser_conditional = {
|
|
5724
|
-
"USER_SKIPPED",
|
|
5725
|
-
"BLOCKED",
|
|
5726
|
-
"BLOCKED_BY_ENV",
|
|
5727
|
-
"NOT_RUN",
|
|
5728
|
-
"PARTIAL",
|
|
5729
|
-
}
|
|
5730
|
-
has_skip = api_status == "USER_SKIPPED" or browser_status in browser_conditional or str(
|
|
5731
|
-
ver.get("dbCompatibility") or ""
|
|
5732
|
-
) == "BLOCKED_BY_DBA"
|
|
5733
|
-
has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
|
|
5734
|
-
has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
|
|
5735
|
-
has_fail_ver = (
|
|
5736
|
-
has_fail_ver
|
|
5737
|
-
or int(browser.get("failed") or 0) > 0
|
|
5738
|
-
or browser_status == "FAIL"
|
|
5739
|
-
)
|
|
5740
|
-
stage = summary.get("stageStatus") or {}
|
|
5741
|
-
has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
|
|
5742
|
-
|
|
5743
|
-
if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
|
|
5539
|
+
except (OSError, json.JSONDecodeError):
|
|
5744
5540
|
issues.append(
|
|
5745
5541
|
{
|
|
5746
|
-
"code": "
|
|
5542
|
+
"code": "manifest-invalid",
|
|
5747
5543
|
"severity": "error",
|
|
5748
|
-
"message":
|
|
5749
|
-
"finalStatus is pure OK but USER_SKIPPED/BLOCKED_BY_DBA/"
|
|
5750
|
-
"failed verification present"
|
|
5751
|
-
),
|
|
5544
|
+
"message": "archive-manifest-before is unreadable",
|
|
5752
5545
|
}
|
|
5753
5546
|
)
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
"message": "final-summary shows pure OK despite USER_SKIPPED/BLOCKED",
|
|
5762
|
-
}
|
|
5763
|
-
)
|
|
5764
|
-
else:
|
|
5765
|
-
# No HTML (and not render_skipped handled above): still check data-level status rules
|
|
5766
|
-
ver = summary.get("verification") or {}
|
|
5767
|
-
unit = ver.get("unitTests") or {}
|
|
5768
|
-
api = ver.get("apiTests") or {}
|
|
5769
|
-
browser = ver.get("browserE2E") or {}
|
|
5770
|
-
api_status = str(api.get("status") or "")
|
|
5771
|
-
browser_status = str(browser.get("status") or "")
|
|
5772
|
-
final_status = str(summary.get("finalStatus") or "")
|
|
5773
|
-
has_skip = api_status == "USER_SKIPPED" or browser_status in {
|
|
5774
|
-
"USER_SKIPPED",
|
|
5775
|
-
"BLOCKED",
|
|
5776
|
-
"BLOCKED_BY_ENV",
|
|
5777
|
-
"NOT_RUN",
|
|
5778
|
-
"PARTIAL",
|
|
5779
|
-
} or str(
|
|
5780
|
-
ver.get("dbCompatibility") or ""
|
|
5781
|
-
) == "BLOCKED_BY_DBA"
|
|
5782
|
-
has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
|
|
5783
|
-
has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
|
|
5784
|
-
has_fail_ver = (
|
|
5785
|
-
has_fail_ver
|
|
5786
|
-
or int(browser.get("failed") or 0) > 0
|
|
5787
|
-
or browser_status == "FAIL"
|
|
5788
|
-
)
|
|
5789
|
-
stage = summary.get("stageStatus") or {}
|
|
5790
|
-
has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
|
|
5791
|
-
if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
|
|
5547
|
+
|
|
5548
|
+
# 5. Artifact paths must resolve to immutable files inside the archive.
|
|
5549
|
+
root = change_dir.resolve()
|
|
5550
|
+
for artifact in summary.get("artifacts") or []:
|
|
5551
|
+
raw = str(artifact.get("path") or "") if isinstance(artifact, dict) else ""
|
|
5552
|
+
candidate = (root / raw).resolve() if raw else root
|
|
5553
|
+
if not raw or not candidate.is_relative_to(root) or not candidate.is_file():
|
|
5792
5554
|
issues.append(
|
|
5793
5555
|
{
|
|
5794
|
-
"code": "
|
|
5556
|
+
"code": "artifact-missing",
|
|
5795
5557
|
"severity": "error",
|
|
5796
|
-
"message":
|
|
5797
|
-
"finalStatus is pure OK but USER_SKIPPED/BLOCKED_BY_DBA/"
|
|
5798
|
-
"failed verification present"
|
|
5799
|
-
),
|
|
5558
|
+
"message": f"artifact path is missing or outside archive: {raw}",
|
|
5800
5559
|
}
|
|
5801
5560
|
)
|
|
5802
5561
|
|
|
@@ -5810,14 +5569,55 @@ def validate_summary(
|
|
|
5810
5569
|
}
|
|
5811
5570
|
|
|
5812
5571
|
|
|
5813
|
-
def
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5572
|
+
def validate_summary_data(summary: dict[str, Any]) -> dict[str, Any]:
|
|
5573
|
+
"""Validate canonical summary data without creating an HTML projection."""
|
|
5574
|
+
issues: list[dict[str, str]] = []
|
|
5575
|
+
verification = summary.get("verification") or {}
|
|
5576
|
+
verification = verification if isinstance(verification, dict) else {}
|
|
5577
|
+
unit = verification.get("unitTests") or {}
|
|
5578
|
+
api = verification.get("apiTests") or {}
|
|
5579
|
+
browser = verification.get("browserE2E") or {}
|
|
5580
|
+
unit = unit if isinstance(unit, dict) else {}
|
|
5581
|
+
api = api if isinstance(api, dict) else {}
|
|
5582
|
+
browser = browser if isinstance(browser, dict) else {}
|
|
5583
|
+
api_status = str(api.get("status") or "")
|
|
5584
|
+
browser_status = str(browser.get("status") or "")
|
|
5585
|
+
final_status = str(summary.get("finalStatus") or "")
|
|
5586
|
+
has_skip = api_status == "USER_SKIPPED" or browser_status in {
|
|
5587
|
+
"USER_SKIPPED",
|
|
5588
|
+
"BLOCKED",
|
|
5589
|
+
"BLOCKED_BY_ENV",
|
|
5590
|
+
"NOT_RUN",
|
|
5591
|
+
"PARTIAL",
|
|
5592
|
+
} or str(verification.get("dbCompatibility") or "") == "BLOCKED_BY_DBA"
|
|
5593
|
+
has_failed_verification = (
|
|
5594
|
+
int(unit.get("failures") or 0) > 0
|
|
5595
|
+
or int(unit.get("errors") or 0) > 0
|
|
5596
|
+
or int(api.get("failed") or 0) > 0
|
|
5597
|
+
or int(browser.get("failed") or 0) > 0
|
|
5598
|
+
or browser_status == "FAIL"
|
|
5820
5599
|
)
|
|
5600
|
+
stage = summary.get("stageStatus") or {}
|
|
5601
|
+
stage = stage if isinstance(stage, dict) else {}
|
|
5602
|
+
has_failed_stage = any(str(value).upper() == "FAIL" for value in stage.values())
|
|
5603
|
+
if (has_skip or has_failed_verification or has_failed_stage) and final_status == "OK":
|
|
5604
|
+
issues.append(
|
|
5605
|
+
{
|
|
5606
|
+
"code": "status-contradiction",
|
|
5607
|
+
"severity": "error",
|
|
5608
|
+
"message": (
|
|
5609
|
+
"finalStatus is pure OK but skipped, blocked, or failed "
|
|
5610
|
+
"verification is present"
|
|
5611
|
+
),
|
|
5612
|
+
}
|
|
5613
|
+
)
|
|
5614
|
+
errors = [item for item in issues if item.get("severity") == "error"]
|
|
5615
|
+
return {
|
|
5616
|
+
"ok": not errors,
|
|
5617
|
+
"issues": issues,
|
|
5618
|
+
"error_count": len(errors),
|
|
5619
|
+
"warning_count": len(issues) - len(errors),
|
|
5620
|
+
}
|
|
5821
5621
|
|
|
5822
5622
|
|
|
5823
5623
|
# ---------------------------------------------------------------------------
|
|
@@ -5834,6 +5634,10 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
5834
5634
|
a lossy projection of itself.
|
|
5835
5635
|
"""
|
|
5836
5636
|
issues: list[dict[str, str]] = []
|
|
5637
|
+
closure_disposition = str(
|
|
5638
|
+
summary.get("closureDisposition") or "completed"
|
|
5639
|
+
).strip()
|
|
5640
|
+
unfinished_closure = closure_disposition in {"abandoned", "superseded"}
|
|
5837
5641
|
|
|
5838
5642
|
# H-12: prefer top-level baseCommit + diffStat; gitFacts is a mirror.
|
|
5839
5643
|
base = str(summary.get("baseCommit") or "").strip()
|
|
@@ -5858,7 +5662,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
5858
5662
|
final_present = bool(
|
|
5859
5663
|
(final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
|
|
5860
5664
|
)
|
|
5861
|
-
if final_present and base_missing:
|
|
5665
|
+
if final_present and base_missing and not unfinished_closure:
|
|
5862
5666
|
issues.append({
|
|
5863
5667
|
"code": "IDENTITY_BASE_MISSING",
|
|
5864
5668
|
"severity": "error",
|
|
@@ -5875,6 +5679,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
5875
5679
|
and final != NOT_AVAILABLE
|
|
5876
5680
|
and base != final
|
|
5877
5681
|
and files_changed == 0
|
|
5682
|
+
and not unfinished_closure
|
|
5878
5683
|
):
|
|
5879
5684
|
issues.append({
|
|
5880
5685
|
"code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
|
|
@@ -5902,13 +5707,14 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
5902
5707
|
if not feature_tip:
|
|
5903
5708
|
feature_tip = str(identity_doc_early.get("productCommit") or "").strip()
|
|
5904
5709
|
|
|
5905
|
-
|
|
5710
|
+
collapsed_boundary = (
|
|
5906
5711
|
base
|
|
5907
5712
|
and base != NOT_AVAILABLE
|
|
5908
5713
|
and feature_tip
|
|
5909
5714
|
and feature_tip != NOT_AVAILABLE
|
|
5910
5715
|
and base == feature_tip
|
|
5911
|
-
)
|
|
5716
|
+
)
|
|
5717
|
+
if collapsed_boundary and not unfinished_closure:
|
|
5912
5718
|
issues.append({
|
|
5913
5719
|
"code": "ARCHIVE_BASE_EQUALS_FEATURE_TIP",
|
|
5914
5720
|
"severity": "error",
|
|
@@ -5917,6 +5723,15 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
5917
5723
|
"archive boundary collapsed to an empty/merge-only delta"
|
|
5918
5724
|
),
|
|
5919
5725
|
})
|
|
5726
|
+
elif unfinished_closure and files_changed == 0:
|
|
5727
|
+
issues.append({
|
|
5728
|
+
"code": "NO_PRODUCT_DELTA",
|
|
5729
|
+
"severity": "warning",
|
|
5730
|
+
"message": (
|
|
5731
|
+
"未完成变更没有产生产品增量;仅封存过程、原因与已有证据,"
|
|
5732
|
+
"不得作为发布候选。"
|
|
5733
|
+
),
|
|
5734
|
+
})
|
|
5920
5735
|
|
|
5921
5736
|
ownership = summary.get("ownershipDiff") or summary.get("ownership")
|
|
5922
5737
|
ownership = ownership if isinstance(ownership, dict) else {}
|
|
@@ -5929,7 +5744,7 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
5929
5744
|
changed_files = summary.get("changedFiles")
|
|
5930
5745
|
changed_count = len(changed_files) if isinstance(changed_files, list) else files_changed
|
|
5931
5746
|
|
|
5932
|
-
if ownership_count > 0 and changed_count == 0:
|
|
5747
|
+
if ownership_count > 0 and changed_count == 0 and not unfinished_closure:
|
|
5933
5748
|
issues.append({
|
|
5934
5749
|
"code": "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP",
|
|
5935
5750
|
"severity": "error",
|
|
@@ -5939,7 +5754,12 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
5939
5754
|
"but does not represent the full change"
|
|
5940
5755
|
),
|
|
5941
5756
|
})
|
|
5942
|
-
elif
|
|
5757
|
+
elif (
|
|
5758
|
+
ownership_count > 0
|
|
5759
|
+
and changed_count > 0
|
|
5760
|
+
and changed_count * 3 < ownership_count
|
|
5761
|
+
and not unfinished_closure
|
|
5762
|
+
):
|
|
5943
5763
|
issues.append({
|
|
5944
5764
|
"code": "ARCHIVE_DIFF_SHRUNK_VS_OWNERSHIP",
|
|
5945
5765
|
"severity": "error",
|
|
@@ -5950,7 +5770,11 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
5950
5770
|
})
|
|
5951
5771
|
|
|
5952
5772
|
merge_parents = summary.get("mergeParents") or summary.get("mergeParentHashes")
|
|
5953
|
-
if
|
|
5773
|
+
if (
|
|
5774
|
+
isinstance(merge_parents, list)
|
|
5775
|
+
and len(merge_parents) >= 2
|
|
5776
|
+
and not unfinished_closure
|
|
5777
|
+
):
|
|
5954
5778
|
first_parent = str(merge_parents[0] or "").strip()
|
|
5955
5779
|
if (
|
|
5956
5780
|
base
|
|
@@ -6173,7 +5997,15 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
6173
5997
|
}
|
|
6174
5998
|
)
|
|
6175
5999
|
|
|
6176
|
-
|
|
6000
|
+
error_count = sum(
|
|
6001
|
+
1 for issue in issues if str(issue.get("severity") or "error") == "error"
|
|
6002
|
+
)
|
|
6003
|
+
return {
|
|
6004
|
+
"ok": error_count == 0,
|
|
6005
|
+
"issues": issues,
|
|
6006
|
+
"error_count": error_count,
|
|
6007
|
+
"warning_count": len(issues) - error_count,
|
|
6008
|
+
}
|
|
6177
6009
|
|
|
6178
6010
|
|
|
6179
6011
|
RELEASE_CHECKS = (
|
|
@@ -7466,6 +7298,7 @@ def cmd_finalize(
|
|
|
7466
7298
|
archive_intent: str = "release-candidate",
|
|
7467
7299
|
closure_disposition: str = "completed",
|
|
7468
7300
|
closure_reason: str = "",
|
|
7301
|
+
preflight_status: dict[str, Any] | None = None,
|
|
7469
7302
|
) -> tuple[int, dict[str, Any]]:
|
|
7470
7303
|
"""Execute the 9-step finalize pipeline. Returns (exit_code, payload)."""
|
|
7471
7304
|
if archive_intent not in {"release-candidate", "record-only"}:
|
|
@@ -7536,6 +7369,11 @@ def cmd_finalize(
|
|
|
7536
7369
|
if resolved_state_dir.resolve() != original_change_dir.resolve()
|
|
7537
7370
|
else None
|
|
7538
7371
|
)
|
|
7372
|
+
authoritative_event_dir = (
|
|
7373
|
+
split_state_dir
|
|
7374
|
+
if split_state_dir is not None and split_state_dir.is_dir()
|
|
7375
|
+
else original_change_dir
|
|
7376
|
+
)
|
|
7539
7377
|
operation_id = f"a-{uuid.uuid4().hex[:12]}"
|
|
7540
7378
|
operation_root = project_root / ".harness" / "archive-operations"
|
|
7541
7379
|
operation_temp_dir = operation_root / "staging" / operation_id / change_dir.name
|
|
@@ -7545,13 +7383,22 @@ def cmd_finalize(
|
|
|
7545
7383
|
shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
|
|
7546
7384
|
payload["original_preserved"] = original_change_dir.is_dir()
|
|
7547
7385
|
payload["finalStatus"] = "FAIL"
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
|
|
7551
|
-
|
|
7552
|
-
|
|
7553
|
-
|
|
7554
|
-
|
|
7386
|
+
if authoritative_event_dir.is_dir():
|
|
7387
|
+
try:
|
|
7388
|
+
append_event(
|
|
7389
|
+
authoritative_event_dir,
|
|
7390
|
+
phase="archive",
|
|
7391
|
+
type_="phase.end",
|
|
7392
|
+
status="FAIL",
|
|
7393
|
+
code=str(
|
|
7394
|
+
payload.get("reasonCode")
|
|
7395
|
+
or "ARCHIVE_FINALIZE_FAILED"
|
|
7396
|
+
),
|
|
7397
|
+
message=str(payload.get("error") or "归档执行失败"),
|
|
7398
|
+
note=f"归档操作 {operation_id} 失败,原变更目录已保留",
|
|
7399
|
+
)
|
|
7400
|
+
except OSError:
|
|
7401
|
+
pass
|
|
7555
7402
|
try:
|
|
7556
7403
|
write_json(
|
|
7557
7404
|
operation_record,
|
|
@@ -7595,6 +7442,65 @@ def cmd_finalize(
|
|
|
7595
7442
|
payload["error"] = f"archive target already exists: {archive_dir}"
|
|
7596
7443
|
return 1, payload
|
|
7597
7444
|
|
|
7445
|
+
status = preflight_status or check_status(
|
|
7446
|
+
original_change_dir,
|
|
7447
|
+
allow_missing_review=allow_missing_review,
|
|
7448
|
+
archive_intent=archive_intent,
|
|
7449
|
+
closure_disposition=closure_disposition,
|
|
7450
|
+
closure_reason=closure_reason,
|
|
7451
|
+
)
|
|
7452
|
+
payload["steps"]["preflight"] = status
|
|
7453
|
+
if not status.get("archivable"):
|
|
7454
|
+
report_adequacy = (
|
|
7455
|
+
(status.get("releaseDecision") or {}).get("checks") or {}
|
|
7456
|
+
).get("reportAdequacy") or {}
|
|
7457
|
+
adequacy_failed = report_adequacy.get("ok") is False
|
|
7458
|
+
payload["reasonCode"] = (
|
|
7459
|
+
"ARCHIVE_REPORT_ADEQUACY_FAILED"
|
|
7460
|
+
if adequacy_failed
|
|
7461
|
+
else "ARCHIVE_PRECONDITIONS_UNSATISFIED"
|
|
7462
|
+
)
|
|
7463
|
+
payload["error"] = (
|
|
7464
|
+
"归档报告不完整,未进入暂存与发布阶段。"
|
|
7465
|
+
if adequacy_failed
|
|
7466
|
+
else "归档前置条件未满足,未进入暂存与发布阶段。"
|
|
7467
|
+
)
|
|
7468
|
+
payload["issues"] = [
|
|
7469
|
+
{
|
|
7470
|
+
"code": str(
|
|
7471
|
+
item.get("code") or "ARCHIVE_PRECONDITION_FAILED"
|
|
7472
|
+
).replace("-", "_").upper(),
|
|
7473
|
+
"severity": "error",
|
|
7474
|
+
"message": str(item.get("message") or payload["error"]),
|
|
7475
|
+
}
|
|
7476
|
+
for item in status.get("blockers") or []
|
|
7477
|
+
if isinstance(item, dict)
|
|
7478
|
+
]
|
|
7479
|
+
payload["original_preserved"] = original_change_dir.is_dir()
|
|
7480
|
+
payload["finalStatus"] = "BLOCKED"
|
|
7481
|
+
_append_finalize_failure_terminal(
|
|
7482
|
+
split_state_dir
|
|
7483
|
+
if split_state_dir is not None and split_state_dir.is_dir()
|
|
7484
|
+
else original_change_dir,
|
|
7485
|
+
payload["error"],
|
|
7486
|
+
operation_id=operation_id,
|
|
7487
|
+
)
|
|
7488
|
+
return 1, payload
|
|
7489
|
+
|
|
7490
|
+
# The formal archive attempt starts after the one-shot preflight succeeds,
|
|
7491
|
+
# before scans and staging begin. Keeping this start in authoritative state
|
|
7492
|
+
# makes the monitor show the real duration and exactly one attempt.
|
|
7493
|
+
if authoritative_event_dir.is_dir():
|
|
7494
|
+
try:
|
|
7495
|
+
append_event(
|
|
7496
|
+
authoritative_event_dir,
|
|
7497
|
+
phase="archive",
|
|
7498
|
+
type_="phase.start",
|
|
7499
|
+
note="归档条件已通过,开始扫描、暂存与发布",
|
|
7500
|
+
)
|
|
7501
|
+
except OSError as exc:
|
|
7502
|
+
warnings.append(f"archive phase start append failed: {exc}")
|
|
7503
|
+
|
|
7598
7504
|
source_sensitive_refresh = hruntime.refresh_sensitive_evidence_scan_receipt(
|
|
7599
7505
|
original_change_dir,
|
|
7600
7506
|
persist=False,
|
|
@@ -7615,8 +7521,7 @@ def cmd_finalize(
|
|
|
7615
7521
|
"message": payload["error"],
|
|
7616
7522
|
}
|
|
7617
7523
|
]
|
|
7618
|
-
|
|
7619
|
-
payload["finalStatus"] = "FAIL"
|
|
7524
|
+
_restore_finalize_failure()
|
|
7620
7525
|
return 1, payload
|
|
7621
7526
|
|
|
7622
7527
|
source_sensitive_gate = validate_sensitive_evidence_publication_gate(
|
|
@@ -7642,8 +7547,7 @@ def cmd_finalize(
|
|
|
7642
7547
|
"message": payload["error"],
|
|
7643
7548
|
}
|
|
7644
7549
|
]
|
|
7645
|
-
|
|
7646
|
-
payload["finalStatus"] = "FAIL"
|
|
7550
|
+
_restore_finalize_failure()
|
|
7647
7551
|
return 1, payload
|
|
7648
7552
|
|
|
7649
7553
|
exact_byte = check_archive_exact_byte_policy(project_root)
|
|
@@ -7674,15 +7578,7 @@ def cmd_finalize(
|
|
|
7674
7578
|
),
|
|
7675
7579
|
}
|
|
7676
7580
|
]
|
|
7677
|
-
|
|
7678
|
-
payload["finalStatus"] = "FAIL"
|
|
7679
|
-
_append_finalize_failure_terminal(
|
|
7680
|
-
split_state_dir
|
|
7681
|
-
if split_state_dir is not None and split_state_dir.is_dir()
|
|
7682
|
-
else original_change_dir,
|
|
7683
|
-
payload["error"],
|
|
7684
|
-
operation_id=operation_id,
|
|
7685
|
-
)
|
|
7581
|
+
_restore_finalize_failure()
|
|
7686
7582
|
return 1, payload
|
|
7687
7583
|
|
|
7688
7584
|
payload["steps"]["service_stop_before_staging"] = run_service_stop(
|
|
@@ -7801,9 +7697,6 @@ def cmd_finalize(
|
|
|
7801
7697
|
except OSError as exc:
|
|
7802
7698
|
warnings.append(f"event append failed: {exc}")
|
|
7803
7699
|
|
|
7804
|
-
# Step 9 starts here: phase.start
|
|
7805
|
-
_safe_append(phase="archive", type_="phase.start", note="finalize start")
|
|
7806
|
-
|
|
7807
7700
|
# --- 0. cleanup transients (before before-manifest) ---
|
|
7808
7701
|
try:
|
|
7809
7702
|
cleanup_result = _cleanup_transients(work_dir)
|
|
@@ -8125,32 +8018,12 @@ def cmd_finalize(
|
|
|
8125
8018
|
payload["ok"] = False
|
|
8126
8019
|
return 1, payload
|
|
8127
8020
|
|
|
8128
|
-
# --- 6.
|
|
8129
|
-
render_result = render_final_summary(work_dir, summary_path)
|
|
8130
|
-
payload["steps"]["render"] = render_result
|
|
8131
|
-
if not render_result.get("ok"):
|
|
8132
|
-
# final-summary.html 是 summary-data.json 的可再生展示层:渲染失败
|
|
8133
|
-
# 降级为 warning,归档继续(平台端可按 summary-data 重新渲染)。
|
|
8134
|
-
msg = str(render_result.get("fallbackReason") or "render failed")
|
|
8135
|
-
warnings.append(f"final-summary render failed (archive continues): {msg}")
|
|
8136
|
-
renderer = render_result.get("renderer")
|
|
8137
|
-
if renderer == "python-fallback" and render_result.get("fallbackReason"):
|
|
8138
|
-
warnings.append(
|
|
8139
|
-
f"node render unavailable; used python-fallback: "
|
|
8140
|
-
f"{render_result.get('fallbackReason')}"
|
|
8141
|
-
)
|
|
8142
|
-
|
|
8143
|
-
html_path = work_dir / "reports" / "final" / "final-summary.html"
|
|
8144
|
-
|
|
8145
|
-
# --- 7. renderer consistency (layer 2) ---
|
|
8021
|
+
# --- 6. canonical summary validation ---
|
|
8146
8022
|
try:
|
|
8147
8023
|
summary = read_json(summary_path)
|
|
8148
8024
|
except (OSError, json.JSONDecodeError):
|
|
8149
8025
|
pass
|
|
8150
|
-
validate_result =
|
|
8151
|
-
summary,
|
|
8152
|
-
html_path if html_path.is_file() else None,
|
|
8153
|
-
)
|
|
8026
|
+
validate_result = validate_summary_data(summary)
|
|
8154
8027
|
payload["steps"]["validate"] = validate_result
|
|
8155
8028
|
summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
|
|
8156
8029
|
"issues"
|
|
@@ -8169,10 +8042,10 @@ def cmd_finalize(
|
|
|
8169
8042
|
warnings.append(f"archive-meta write failed: {exc}")
|
|
8170
8043
|
payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
|
|
8171
8044
|
|
|
8172
|
-
# --- 9/10. final summary stats
|
|
8173
|
-
# Post-manifest rewrites of covered bytes are forbidden. We update
|
|
8174
|
-
#
|
|
8175
|
-
# If summary
|
|
8045
|
+
# --- 9/10. final summary stats, then LAST manifest (IA-7) ---
|
|
8046
|
+
# Post-manifest rewrites of covered bytes are forbidden. We update the
|
|
8047
|
+
# summary first, regenerate after-manifest last, then verify on-disk hashes.
|
|
8048
|
+
# If summary must still change after that, it is excluded with a reason.
|
|
8176
8049
|
summary = read_json(summary_path)
|
|
8177
8050
|
summary["archiveManifest"] = {
|
|
8178
8051
|
"movedFiles": 0,
|
|
@@ -8181,14 +8054,8 @@ def cmd_finalize(
|
|
|
8181
8054
|
"checksumStatus": "PENDING",
|
|
8182
8055
|
}
|
|
8183
8056
|
write_json(summary_path, summary)
|
|
8184
|
-
render_result = render_final_summary(work_dir, summary_path)
|
|
8185
|
-
if not render_result.get("ok"):
|
|
8186
|
-
warnings.append(
|
|
8187
|
-
"final summary re-render failed (archive continues): "
|
|
8188
|
-
f"{render_result.get('fallbackReason') or render_result.get('error')}"
|
|
8189
|
-
)
|
|
8190
8057
|
summary = read_json(summary_path)
|
|
8191
|
-
validate_result =
|
|
8058
|
+
validate_result = validate_summary_data(summary)
|
|
8192
8059
|
payload["steps"]["validate"] = validate_result
|
|
8193
8060
|
summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
|
|
8194
8061
|
"issues"
|
|
@@ -8214,25 +8081,15 @@ def cmd_finalize(
|
|
|
8214
8081
|
"reports/final/summary-data.json": (
|
|
8215
8082
|
"archiveManifest stats written after coverage snapshot"
|
|
8216
8083
|
),
|
|
8217
|
-
"reports/final/final-summary.html": (
|
|
8218
|
-
"re-rendered after coverage snapshot to display manifest stats"
|
|
8219
|
-
),
|
|
8220
8084
|
},
|
|
8221
8085
|
**_manifest_self_stats(work_dir, after_manifest, after_path),
|
|
8222
8086
|
}
|
|
8223
8087
|
write_json(summary_path, summary)
|
|
8224
|
-
render_result = render_final_summary(work_dir, summary_path)
|
|
8225
|
-
if not render_result.get("ok"):
|
|
8226
|
-
warnings.append(
|
|
8227
|
-
"post-manifest summary render failed (archive continues): "
|
|
8228
|
-
f"{render_result.get('fallbackReason') or render_result.get('error')}"
|
|
8229
|
-
)
|
|
8230
8088
|
coverage = verify_manifest_byte_coverage(
|
|
8231
8089
|
work_dir,
|
|
8232
8090
|
after_manifest,
|
|
8233
8091
|
exclude_paths=[
|
|
8234
8092
|
"reports/final/summary-data.json",
|
|
8235
|
-
"reports/final/final-summary.html",
|
|
8236
8093
|
],
|
|
8237
8094
|
exclusion_reasons=summary["archiveManifest"]["exclusionReasons"],
|
|
8238
8095
|
)
|
|
@@ -8288,12 +8145,8 @@ def cmd_finalize(
|
|
|
8288
8145
|
]
|
|
8289
8146
|
payload["releaseEligible"] = summary["releaseEligible"]
|
|
8290
8147
|
write_json(summary_path, summary)
|
|
8291
|
-
# Re-render once more for checksumStatus display; remain excluded.
|
|
8292
|
-
render_final_summary(work_dir, summary_path)
|
|
8293
8148
|
summary = read_json(summary_path)
|
|
8294
|
-
validate_result =
|
|
8295
|
-
summary, html_path if html_path.is_file() else None
|
|
8296
|
-
)
|
|
8149
|
+
validate_result = validate_summary_data(summary)
|
|
8297
8150
|
payload["steps"]["validate"] = validate_result
|
|
8298
8151
|
summary.setdefault("reportPipeline", {})["validationIssues"] = (
|
|
8299
8152
|
validate_result.get("issues") or []
|
|
@@ -8407,7 +8260,6 @@ def cmd_finalize(
|
|
|
8407
8260
|
shutil.rmtree(operation_temp_dir.parent, ignore_errors=True)
|
|
8408
8261
|
work_dir = archive_dir
|
|
8409
8262
|
summary_path = work_dir / "reports" / "final" / "summary-data.json"
|
|
8410
|
-
html_path = work_dir / "reports" / "final" / "final-summary.html"
|
|
8411
8263
|
payload["steps"]["move"] = {"ok": True, "to": str(archive_dir)}
|
|
8412
8264
|
except OSError as exc:
|
|
8413
8265
|
payload["error"] = f"publish failed: {exc}"
|
|
@@ -8508,7 +8360,6 @@ def cmd_finalize(
|
|
|
8508
8360
|
payload["finalStatus"] = "OK"
|
|
8509
8361
|
payload["warnings"] = warnings
|
|
8510
8362
|
payload["summary_data"] = str(summary_path)
|
|
8511
|
-
payload["final_summary"] = str(html_path) if html_path.is_file() else None
|
|
8512
8363
|
return 0, payload
|
|
8513
8364
|
|
|
8514
8365
|
|
|
@@ -8845,6 +8696,59 @@ def _deterministic_zip_info(path: str) -> zipfile.ZipInfo:
|
|
|
8845
8696
|
return info
|
|
8846
8697
|
|
|
8847
8698
|
|
|
8699
|
+
_WINDOWS_LOCAL_PATH_RE = re.compile(
|
|
8700
|
+
r"\b[A-Za-z]:\\(?:[^\s<>:\"|?*]+\\)*[^\s<>:\"|?*]*"
|
|
8701
|
+
)
|
|
8702
|
+
|
|
8703
|
+
|
|
8704
|
+
def _archive_remote_json_value(value: Any) -> Any:
|
|
8705
|
+
"""Remove machine-local absolute paths from the remote JSON projection."""
|
|
8706
|
+
if isinstance(value, dict):
|
|
8707
|
+
return {
|
|
8708
|
+
key: _archive_remote_json_value(item)
|
|
8709
|
+
for key, item in value.items()
|
|
8710
|
+
}
|
|
8711
|
+
if isinstance(value, list):
|
|
8712
|
+
return [_archive_remote_json_value(item) for item in value]
|
|
8713
|
+
if not isinstance(value, str):
|
|
8714
|
+
return value
|
|
8715
|
+
if re.match(r"^[A-Za-z]:[\\/]", value) or value.startswith("\\\\"):
|
|
8716
|
+
return "<local-path>"
|
|
8717
|
+
return _WINDOWS_LOCAL_PATH_RE.sub("<local-path>", value)
|
|
8718
|
+
|
|
8719
|
+
|
|
8720
|
+
def _archive_remote_json_bytes(raw: bytes) -> bytes:
|
|
8721
|
+
value = json.loads(raw)
|
|
8722
|
+
projected = _archive_remote_json_value(value)
|
|
8723
|
+
return (
|
|
8724
|
+
json.dumps(
|
|
8725
|
+
projected,
|
|
8726
|
+
ensure_ascii=False,
|
|
8727
|
+
sort_keys=True,
|
|
8728
|
+
separators=(",", ":"),
|
|
8729
|
+
)
|
|
8730
|
+
+ "\n"
|
|
8731
|
+
).encode("utf-8")
|
|
8732
|
+
|
|
8733
|
+
|
|
8734
|
+
def resolve_npx_launcher() -> list[str]:
|
|
8735
|
+
"""Resolve npx without asking CreateProcess to execute a Windows .CMD shim."""
|
|
8736
|
+
npx = shutil.which("npx")
|
|
8737
|
+
if not npx:
|
|
8738
|
+
raise FileNotFoundError("npx is not installed or not available on PATH")
|
|
8739
|
+
if os.name != "nt":
|
|
8740
|
+
return [npx]
|
|
8741
|
+
|
|
8742
|
+
node = shutil.which("node")
|
|
8743
|
+
if not node:
|
|
8744
|
+
raise FileNotFoundError("node is required to launch npx on Windows")
|
|
8745
|
+
npx_path = Path(npx)
|
|
8746
|
+
npx_cli = npx_path.parent / "node_modules" / "npm" / "bin" / "npx-cli.js"
|
|
8747
|
+
if not npx_cli.is_file():
|
|
8748
|
+
raise FileNotFoundError(f"npx-cli.js not found beside Node.js: {npx_cli}")
|
|
8749
|
+
return [node, str(npx_cli)]
|
|
8750
|
+
|
|
8751
|
+
|
|
8848
8752
|
def build_archive_package(
|
|
8849
8753
|
project_root: Path,
|
|
8850
8754
|
archive_dir: Path,
|
|
@@ -8875,7 +8779,7 @@ def build_archive_package(
|
|
|
8875
8779
|
# All semantic archive inputs are text and must round-trip as UTF-8.
|
|
8876
8780
|
raw.decode("utf-8")
|
|
8877
8781
|
if media_type == "application/json":
|
|
8878
|
-
|
|
8782
|
+
raw = _archive_remote_json_bytes(raw)
|
|
8879
8783
|
content_by_path[package_path] = raw
|
|
8880
8784
|
entries.append(
|
|
8881
8785
|
{
|
|
@@ -9104,8 +9008,21 @@ def auto_push_archive_core(
|
|
|
9104
9008
|
|
|
9105
9009
|
# The published CLI owns credentials, project binding, durable receipt checks,
|
|
9106
9010
|
# and retries. Generic `push` is intentionally not used for archive bytes.
|
|
9011
|
+
try:
|
|
9012
|
+
launcher = resolve_npx_launcher()
|
|
9013
|
+
except OSError as exc:
|
|
9014
|
+
reason_code = "ARCHIVE_UPLOAD_DEFERRED"
|
|
9015
|
+
try:
|
|
9016
|
+
persist_receipt(upload_status="pending", reason_code=reason_code)
|
|
9017
|
+
except OSError:
|
|
9018
|
+
pass
|
|
9019
|
+
return {
|
|
9020
|
+
**base_result,
|
|
9021
|
+
"reasonCode": reason_code,
|
|
9022
|
+
"warning": f"归档自动上传已延后:{exc}",
|
|
9023
|
+
}
|
|
9107
9024
|
command = [
|
|
9108
|
-
|
|
9025
|
+
*launcher,
|
|
9109
9026
|
"--yes",
|
|
9110
9027
|
"hunter-harness",
|
|
9111
9028
|
"archive",
|
|
@@ -9359,13 +9276,7 @@ def cmd_replay(
|
|
|
9359
9276
|
# Collect without writing into the archive
|
|
9360
9277
|
summary = collect_summary_data(archive_dir, write=False, for_replay=True)
|
|
9361
9278
|
|
|
9362
|
-
|
|
9363
|
-
render_skipped = not html_path.is_file()
|
|
9364
|
-
validate_result = validate_summary(
|
|
9365
|
-
summary,
|
|
9366
|
-
html_path if html_path.is_file() else None,
|
|
9367
|
-
render_skipped=render_skipped,
|
|
9368
|
-
)
|
|
9279
|
+
validate_result = validate_summary_data(summary)
|
|
9369
9280
|
summary.setdefault("reportPipeline", {})["validationIssues"] = (
|
|
9370
9281
|
validate_result.get("issues") or []
|
|
9371
9282
|
)
|
|
@@ -9423,6 +9334,20 @@ def cmd_auto_gate_cli(args: argparse.Namespace) -> int:
|
|
|
9423
9334
|
resolve_path(args.change_dir),
|
|
9424
9335
|
allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
|
|
9425
9336
|
archive_intent=str(getattr(args, "intent", "release-candidate")),
|
|
9337
|
+
closure_disposition=str(getattr(args, "closure", "completed")),
|
|
9338
|
+
closure_reason=str(getattr(args, "closure_reason", "")),
|
|
9339
|
+
)
|
|
9340
|
+
emit_json(result)
|
|
9341
|
+
return 0 if result.get("ok") else 1
|
|
9342
|
+
|
|
9343
|
+
|
|
9344
|
+
def cmd_adopt_existing_range_cli(args: argparse.Namespace) -> int:
|
|
9345
|
+
result = adopt_existing_range(
|
|
9346
|
+
resolve_path(args.change_dir),
|
|
9347
|
+
base=str(args.base),
|
|
9348
|
+
tip=str(args.tip),
|
|
9349
|
+
reason=str(args.reason),
|
|
9350
|
+
confirmed=bool(getattr(args, "confirm_existing_range", False)),
|
|
9426
9351
|
)
|
|
9427
9352
|
emit_json(result)
|
|
9428
9353
|
return 0 if result.get("ok") else 1
|
|
@@ -9462,9 +9387,6 @@ def cmd_certify_local_cli(args: argparse.Namespace) -> int:
|
|
|
9462
9387
|
|
|
9463
9388
|
|
|
9464
9389
|
def cmd_finalize_cli(args: argparse.Namespace) -> int:
|
|
9465
|
-
global RENDER_HTML_ENABLED
|
|
9466
|
-
if getattr(args, "no_html", False):
|
|
9467
|
-
RENDER_HTML_ENABLED = False
|
|
9468
9390
|
change_dir = resolve_path(args.change_dir)
|
|
9469
9391
|
archive_root = resolve_path(args.archive_root)
|
|
9470
9392
|
durable_root = (
|
|
@@ -9489,6 +9411,29 @@ def cmd_finalize_cli(args: argparse.Namespace) -> int:
|
|
|
9489
9411
|
return code
|
|
9490
9412
|
|
|
9491
9413
|
|
|
9414
|
+
def cmd_execute_cli(args: argparse.Namespace) -> int:
|
|
9415
|
+
durable_root = (
|
|
9416
|
+
resolve_path(args.durable_root)
|
|
9417
|
+
if getattr(args, "durable_root", None)
|
|
9418
|
+
else None
|
|
9419
|
+
)
|
|
9420
|
+
code, payload = execute_archive(
|
|
9421
|
+
resolve_path(args.change_dir),
|
|
9422
|
+
resolve_path(args.archive_root),
|
|
9423
|
+
durable_root=durable_root,
|
|
9424
|
+
retention_policy=str(
|
|
9425
|
+
getattr(args, "retention_policy", "unspecified")
|
|
9426
|
+
),
|
|
9427
|
+
skip_ingest=bool(args.skip_ingest),
|
|
9428
|
+
allow_missing_review=bool(getattr(args, "allow_missing_review", False)),
|
|
9429
|
+
archive_intent=str(getattr(args, "intent", "release-candidate")),
|
|
9430
|
+
closure_disposition=str(getattr(args, "closure", "completed")),
|
|
9431
|
+
closure_reason=str(getattr(args, "closure_reason", "")),
|
|
9432
|
+
)
|
|
9433
|
+
emit_json(payload)
|
|
9434
|
+
return code
|
|
9435
|
+
|
|
9436
|
+
|
|
9492
9437
|
def cmd_restore_durable_cli(args: argparse.Namespace) -> int:
|
|
9493
9438
|
receipt_path = resolve_path(args.receipt)
|
|
9494
9439
|
target_root = resolve_path(args.target_root)
|
|
@@ -9516,50 +9461,12 @@ def cmd_replay_cli(args: argparse.Namespace) -> int:
|
|
|
9516
9461
|
return code
|
|
9517
9462
|
|
|
9518
9463
|
|
|
9519
|
-
def _render_html_to(
|
|
9520
|
-
change_dir: Path,
|
|
9521
|
-
summary_path: Path,
|
|
9522
|
-
out_path: Path,
|
|
9523
|
-
) -> dict[str, Any]:
|
|
9524
|
-
"""Render summary HTML to an arbitrary path (node first, python fallback).
|
|
9525
|
-
|
|
9526
|
-
Unlike ``render_final_summary`` this never touches the canonical
|
|
9527
|
-
``reports/final/final-summary.html`` — repair renders into staging.
|
|
9528
|
-
"""
|
|
9529
|
-
project = find_project_root(change_dir)
|
|
9530
|
-
node = resolve_node_path(project)
|
|
9531
|
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
9532
|
-
if node and RENDER_SCRIPT.is_file():
|
|
9533
|
-
try:
|
|
9534
|
-
proc = subprocess.run(
|
|
9535
|
-
[node, str(RENDER_SCRIPT), "--summary", str(summary_path), "--out", str(out_path)],
|
|
9536
|
-
capture_output=True,
|
|
9537
|
-
text=True,
|
|
9538
|
-
encoding="utf-8",
|
|
9539
|
-
errors="replace",
|
|
9540
|
-
timeout=60,
|
|
9541
|
-
check=False,
|
|
9542
|
-
)
|
|
9543
|
-
if proc.returncode == 0 and out_path.is_file():
|
|
9544
|
-
return {"ok": True, "renderer": "node", "out_path": str(out_path)}
|
|
9545
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
9546
|
-
pass
|
|
9547
|
-
try:
|
|
9548
|
-
summary = read_json(summary_path)
|
|
9549
|
-
html = render_fallback_html(summary)
|
|
9550
|
-
out_path.write_text(html, encoding="utf-8", newline="\n")
|
|
9551
|
-
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
9552
|
-
return {"ok": False, "renderer": "none", "error": str(exc)}
|
|
9553
|
-
return {"ok": True, "renderer": "python-fallback", "out_path": str(out_path)}
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
9464
|
def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
|
|
9557
9465
|
"""Versioned repair (task 11 / RET-40).
|
|
9558
9466
|
|
|
9559
|
-
Re-collect a candidate from the frozen sources,
|
|
9560
|
-
|
|
9561
|
-
original summary
|
|
9562
|
-
pointer moves only when both validators pass.
|
|
9467
|
+
Re-collect a candidate from the frozen sources, validate its source and
|
|
9468
|
+
canonical data, then write an immutable ``derived/v<N>/`` plus a repair
|
|
9469
|
+
record. The original summary and manifest are never overwritten.
|
|
9563
9470
|
"""
|
|
9564
9471
|
payload: dict[str, Any] = {
|
|
9565
9472
|
"ok": False,
|
|
@@ -9596,25 +9503,18 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
|
|
|
9596
9503
|
payload["error"] = f"repair collect failed: {exc}"
|
|
9597
9504
|
return 1, payload
|
|
9598
9505
|
|
|
9599
|
-
# 2. stage outside the archive;
|
|
9506
|
+
# 2. stage outside the archive; validate canonical data only.
|
|
9600
9507
|
staging = Path(tempfile.mkdtemp(prefix="harness-repair-"))
|
|
9601
9508
|
try:
|
|
9602
9509
|
staged_summary = staging / "summary-data.json"
|
|
9603
9510
|
write_json(staged_summary, candidate)
|
|
9604
9511
|
source_result = validate_source_consistency(archive_dir, candidate)
|
|
9605
|
-
|
|
9606
|
-
archive_dir, staged_summary, staging / "final-summary.html"
|
|
9607
|
-
)
|
|
9608
|
-
staged_html = staging / "final-summary.html"
|
|
9609
|
-
renderer_result = validate_summary(
|
|
9610
|
-
candidate,
|
|
9611
|
-
staged_html if staged_html.is_file() else None,
|
|
9612
|
-
)
|
|
9512
|
+
summary_result = validate_summary_data(candidate)
|
|
9613
9513
|
payload["validators"] = {
|
|
9614
9514
|
"source": source_result,
|
|
9615
|
-
"
|
|
9515
|
+
"summary": summary_result,
|
|
9616
9516
|
}
|
|
9617
|
-
if not (source_result.get("ok") and
|
|
9517
|
+
if not (source_result.get("ok") and summary_result.get("ok")):
|
|
9618
9518
|
payload["error"] = "repair validators failed; derived version not written"
|
|
9619
9519
|
return 1, payload
|
|
9620
9520
|
|
|
@@ -9637,8 +9537,6 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
|
|
|
9637
9537
|
target_manifest = staged_version_dir / manifest_name
|
|
9638
9538
|
shutil.copy2(source_manifest, target_manifest)
|
|
9639
9539
|
frozen_manifest_hashes[manifest_name] = "sha256:" + sha256_file(target_manifest)
|
|
9640
|
-
if staged_html.is_file():
|
|
9641
|
-
shutil.copy2(staged_html, staged_version_dir / "final-summary.html")
|
|
9642
9540
|
record = {
|
|
9643
9541
|
"version": version,
|
|
9644
9542
|
"createdAt": now_iso(),
|
|
@@ -9647,7 +9545,7 @@ def cmd_repair(archive_dir: Path) -> tuple[int, dict[str, Any]]:
|
|
|
9647
9545
|
"frozenManifestHashes": frozen_manifest_hashes,
|
|
9648
9546
|
"validators": {
|
|
9649
9547
|
"source": {"ok": bool(source_result.get("ok")), "issues": source_result.get("issues") or []},
|
|
9650
|
-
"
|
|
9548
|
+
"summary": {"ok": bool(summary_result.get("ok")), "issues": summary_result.get("issues") or []},
|
|
9651
9549
|
},
|
|
9652
9550
|
}
|
|
9653
9551
|
write_json(staged_version_dir / "repair-record.json", record)
|
|
@@ -9723,8 +9621,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9723
9621
|
choices=("release-candidate", "record-only"),
|
|
9724
9622
|
default="release-candidate",
|
|
9725
9623
|
)
|
|
9624
|
+
p_auto_gate.add_argument(
|
|
9625
|
+
"--closure",
|
|
9626
|
+
choices=("completed", "abandoned", "superseded"),
|
|
9627
|
+
default="completed",
|
|
9628
|
+
)
|
|
9629
|
+
p_auto_gate.add_argument("--closure-reason", default="")
|
|
9726
9630
|
p_auto_gate.set_defaults(func=cmd_auto_gate_cli)
|
|
9727
9631
|
|
|
9632
|
+
p_adopt = sub.add_parser(
|
|
9633
|
+
"adopt-existing-range",
|
|
9634
|
+
help="explicitly bind an already committed product range to this change",
|
|
9635
|
+
)
|
|
9636
|
+
p_adopt.add_argument("--change-dir", required=True)
|
|
9637
|
+
p_adopt.add_argument("--base", required=True)
|
|
9638
|
+
p_adopt.add_argument("--tip", required=True)
|
|
9639
|
+
p_adopt.add_argument("--reason", required=True)
|
|
9640
|
+
p_adopt.add_argument("--confirm-existing-range", action="store_true")
|
|
9641
|
+
p_adopt.add_argument("--json", action="store_true", default=True)
|
|
9642
|
+
p_adopt.set_defaults(func=cmd_adopt_existing_range_cli)
|
|
9643
|
+
|
|
9728
9644
|
p_cert = sub.add_parser(
|
|
9729
9645
|
"certify-local",
|
|
9730
9646
|
help="create candidate receipt from existing full ledger evidence without rerunning tests",
|
|
@@ -9769,14 +9685,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
9769
9685
|
default="release-candidate",
|
|
9770
9686
|
help="release-candidate enforces candidate evidence; record-only archives facts without release eligibility",
|
|
9771
9687
|
)
|
|
9772
|
-
p_fin.add_argument(
|
|
9773
|
-
"--no-html",
|
|
9774
|
-
action="store_true",
|
|
9775
|
-
help="skip local final-summary.html rendering (summary-data.json stays canonical)",
|
|
9776
|
-
)
|
|
9777
9688
|
p_fin.add_argument("--json", action="store_true", default=True)
|
|
9778
9689
|
p_fin.set_defaults(func=cmd_finalize_cli)
|
|
9779
9690
|
|
|
9691
|
+
p_execute = sub.add_parser(
|
|
9692
|
+
"execute",
|
|
9693
|
+
help="run one archive preflight, boundary gate, finalize, ZIP, and upload flow",
|
|
9694
|
+
)
|
|
9695
|
+
p_execute.add_argument("--change-dir", required=True)
|
|
9696
|
+
p_execute.add_argument("--archive-root", required=True)
|
|
9697
|
+
p_execute.add_argument("--durable-root", default=None)
|
|
9698
|
+
p_execute.add_argument(
|
|
9699
|
+
"--closure",
|
|
9700
|
+
choices=("completed", "abandoned", "superseded"),
|
|
9701
|
+
default="completed",
|
|
9702
|
+
)
|
|
9703
|
+
p_execute.add_argument("--closure-reason", default="")
|
|
9704
|
+
p_execute.add_argument("--retention-policy", default="unspecified")
|
|
9705
|
+
p_execute.add_argument("--skip-ingest", action="store_true")
|
|
9706
|
+
p_execute.add_argument("--allow-missing-review", action="store_true")
|
|
9707
|
+
p_execute.add_argument(
|
|
9708
|
+
"--intent",
|
|
9709
|
+
choices=("release-candidate", "record-only"),
|
|
9710
|
+
default="release-candidate",
|
|
9711
|
+
)
|
|
9712
|
+
p_execute.add_argument("--json", action="store_true", default=True)
|
|
9713
|
+
p_execute.set_defaults(func=cmd_execute_cli)
|
|
9714
|
+
|
|
9780
9715
|
p_restore = sub.add_parser(
|
|
9781
9716
|
"restore-durable",
|
|
9782
9717
|
help="verify a durable receipt and restore its archive without overwriting",
|