@hunter-harness/workflow-harness 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/reference.md +12 -2
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +141 -2
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/reference.md +2 -0
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/claude-code/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/claude-code/harness-sync/reference.md +2 -2
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/reference.md +2 -0
- package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +803 -274
- package/harness/bundles/general/claude-code/scripts/harness_change.py +18 -0
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/claude-code/scripts/harness_events.py +104 -6
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +25 -4
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +440 -10
- package/harness/bundles/general/claude-code/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/claude-code/scripts/harness_review.py +326 -0
- package/harness/bundles/general/claude-code/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/reference.md +12 -2
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +141 -2
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/reference.md +2 -0
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +2 -2
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -0
- package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +803 -274
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +18 -0
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +104 -6
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +25 -4
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +440 -10
- package/harness/bundles/general/codebuddy/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/codebuddy/scripts/harness_review.py +326 -0
- package/harness/bundles/general/codebuddy/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-archive/reference.md +12 -2
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +141 -2
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/reference.md +2 -0
- package/harness/bundles/general/codex/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/codex/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/codex/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/codex/harness-sync/reference.md +2 -2
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/reference.md +2 -0
- package/harness/bundles/general/codex/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/general/codex/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/general/codex/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +803 -274
- package/harness/bundles/general/codex/scripts/harness_change.py +18 -0
- package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/codex/scripts/harness_events.py +104 -6
- package/harness/bundles/general/codex/scripts/harness_gate.py +25 -4
- package/harness/bundles/general/codex/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/codex/scripts/harness_ledger.py +440 -10
- package/harness/bundles/general/codex/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/codex/scripts/harness_review.py +326 -0
- package/harness/bundles/general/codex/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/reference.md +12 -2
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +141 -2
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/reference.md +2 -0
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +15 -10
- package/harness/bundles/general/cursor/harness-submit/checklist.md +40 -76
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +17 -2
- package/harness/bundles/general/cursor/harness-sync/reference.md +2 -2
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/reference.md +2 -0
- package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/general/cursor/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +803 -274
- package/harness/bundles/general/cursor/scripts/harness_change.py +18 -0
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -44
- package/harness/bundles/general/cursor/scripts/harness_events.py +104 -6
- package/harness/bundles/general/cursor/scripts/harness_gate.py +25 -4
- package/harness/bundles/general/cursor/scripts/harness_integration.py +878 -0
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +440 -10
- package/harness/bundles/general/cursor/scripts/harness_paths.py +315 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +45 -0
- package/harness/bundles/general/cursor/scripts/harness_review.py +326 -0
- package/harness/bundles/general/cursor/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/reference.md +12 -2
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +141 -2
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +15 -10
- package/harness/bundles/java/claude-code/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/claude-code/harness-sync/reference.md +2 -2
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +803 -274
- package/harness/bundles/java/claude-code/scripts/harness_change.py +18 -0
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/claude-code/scripts/harness_events.py +104 -6
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +25 -4
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +440 -10
- package/harness/bundles/java/claude-code/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/claude-code/scripts/harness_review.py +326 -0
- package/harness/bundles/java/claude-code/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/reference.md +12 -2
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +141 -2
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +15 -10
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +2 -2
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +803 -274
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +18 -0
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +104 -6
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +25 -4
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +440 -10
- package/harness/bundles/java/codebuddy/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/codebuddy/scripts/harness_review.py +326 -0
- package/harness/bundles/java/codebuddy/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/reference.md +12 -2
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +141 -2
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +15 -10
- package/harness/bundles/java/codex/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/codex/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/codex/harness-sync/reference.md +2 -2
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/java/codex/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/java/codex/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +803 -274
- package/harness/bundles/java/codex/scripts/harness_change.py +18 -0
- package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/codex/scripts/harness_events.py +104 -6
- package/harness/bundles/java/codex/scripts/harness_gate.py +25 -4
- package/harness/bundles/java/codex/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/codex/scripts/harness_ledger.py +440 -10
- package/harness/bundles/java/codex/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/codex/scripts/harness_review.py +326 -0
- package/harness/bundles/java/codex/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +244 -49
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/reference.md +12 -2
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +6 -2
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +3 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +7 -0
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +141 -2
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +15 -10
- package/harness/bundles/java/cursor/harness-submit/checklist.md +40 -76
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +17 -2
- package/harness/bundles/java/cursor/harness-sync/reference.md +2 -2
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +24 -4
- package/harness/bundles/java/cursor/protocols/ledger-protocol.md +36 -0
- package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +15 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +803 -274
- package/harness/bundles/java/cursor/scripts/harness_change.py +18 -0
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -44
- package/harness/bundles/java/cursor/scripts/harness_events.py +104 -6
- package/harness/bundles/java/cursor/scripts/harness_gate.py +25 -4
- package/harness/bundles/java/cursor/scripts/harness_integration.py +878 -0
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +440 -10
- package/harness/bundles/java/cursor/scripts/harness_paths.py +315 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +45 -0
- package/harness/bundles/java/cursor/scripts/harness_review.py +326 -0
- package/harness/bundles/java/cursor/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +244 -49
- package/harness/manifests/general/claude-code.json +47 -31
- package/harness/manifests/general/codebuddy.json +47 -31
- package/harness/manifests/general/codex.json +47 -31
- package/harness/manifests/general/cursor.json +47 -31
- package/harness/manifests/java/claude-code.json +47 -31
- package/harness/manifests/java/codebuddy.json +47 -31
- package/harness/manifests/java/codex.json +47 -31
- package/harness/manifests/java/cursor.json +47 -31
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Harness path and identity resolution.
|
|
3
|
+
|
|
4
|
+
Single owner for:
|
|
5
|
+
- repository identity (stable across worktrees of one repo);
|
|
6
|
+
- Change contract loading (``meta/change-context.json``);
|
|
7
|
+
- dual-root layout resolution (static contract root vs dynamic state root);
|
|
8
|
+
- path boundary assertions used by cleanup/integration code.
|
|
9
|
+
|
|
10
|
+
Python 3.10+, stdlib only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
26
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
28
|
+
|
|
29
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
30
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
31
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
32
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
LIFECYCLE_STATUSES = {"draft", "active", "superseded", "archived", "cancelled"}
|
|
35
|
+
CHANGE_CONTEXT_REL = Path("meta") / "change-context.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git_text(cwd: Path, *args: str) -> str | None:
|
|
39
|
+
proc = subprocess.run(
|
|
40
|
+
["git", *args],
|
|
41
|
+
cwd=str(cwd),
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
errors="replace",
|
|
46
|
+
check=False,
|
|
47
|
+
)
|
|
48
|
+
if proc.returncode != 0:
|
|
49
|
+
return None
|
|
50
|
+
return proc.stdout.strip()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_main_project_root(cwd: Path | None = None) -> Path:
|
|
54
|
+
"""Locate the main project root from a worktree or the main checkout."""
|
|
55
|
+
start = (cwd or Path.cwd()).resolve()
|
|
56
|
+
common_raw = _git_text(start, "rev-parse", "--git-common-dir")
|
|
57
|
+
if not common_raw:
|
|
58
|
+
return start
|
|
59
|
+
common = Path(common_raw)
|
|
60
|
+
if not common.is_absolute():
|
|
61
|
+
common = (start / common).resolve()
|
|
62
|
+
if common.name == ".git":
|
|
63
|
+
return common.parent
|
|
64
|
+
return start
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_worktree_root(cwd: Path | None = None) -> Path:
|
|
68
|
+
"""Top-level of the checkout containing ``cwd`` (worktree-aware)."""
|
|
69
|
+
start = (cwd or Path.cwd()).resolve()
|
|
70
|
+
top = _git_text(start, "rev-parse", "--show-toplevel")
|
|
71
|
+
if not top:
|
|
72
|
+
return start
|
|
73
|
+
return Path(top).resolve()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _git_common_dir(cwd: Path) -> Path | None:
|
|
77
|
+
raw = _git_text(cwd, "rev-parse", "--git-common-dir")
|
|
78
|
+
if not raw:
|
|
79
|
+
return None
|
|
80
|
+
common = Path(raw)
|
|
81
|
+
if not common.is_absolute():
|
|
82
|
+
common = (cwd / common).resolve()
|
|
83
|
+
return common
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _root_commit(cwd: Path) -> str | None:
|
|
87
|
+
raw = _git_text(cwd, "rev-list", "--max-parents=0", "HEAD")
|
|
88
|
+
if not raw:
|
|
89
|
+
return None
|
|
90
|
+
return raw.splitlines()[0].strip()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_remote(url: str) -> str:
|
|
94
|
+
"""Normalize a git remote URL to ``host/path`` lowercase form."""
|
|
95
|
+
text = url.strip()
|
|
96
|
+
text = re.sub(r"^git@", "", text)
|
|
97
|
+
text = text.replace(":", "/", 1) if "://" not in text else text
|
|
98
|
+
text = re.sub(r"^[a-zA-Z]+://", "", text)
|
|
99
|
+
text = re.sub(r"^[^/@]+@", "", text) # strip userinfo
|
|
100
|
+
text = text.lower()
|
|
101
|
+
if text.endswith(".git"):
|
|
102
|
+
text = text[: -len(".git")]
|
|
103
|
+
return text.rstrip("/")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _primary_remote_url(cwd: Path) -> str | None:
|
|
107
|
+
url = _git_text(cwd, "config", "--get", "remote.origin.url")
|
|
108
|
+
if url:
|
|
109
|
+
return url
|
|
110
|
+
remotes = _git_text(cwd, "remote")
|
|
111
|
+
if not remotes:
|
|
112
|
+
return None
|
|
113
|
+
first = remotes.splitlines()[0].strip()
|
|
114
|
+
return _git_text(cwd, "config", "--get", f"remote.{first}.url")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def repository_identity(cwd: Path) -> str:
|
|
118
|
+
"""Stable repository identity.
|
|
119
|
+
|
|
120
|
+
Preferred: normalized primary remote identity + root commit.
|
|
121
|
+
Fallback (no remote): resolved git common-dir identity + root commit.
|
|
122
|
+
The identity never embeds a worktree absolute path, so linked worktrees of
|
|
123
|
+
one repository share the same id (RET-09).
|
|
124
|
+
"""
|
|
125
|
+
cwd = Path(cwd).resolve()
|
|
126
|
+
root_commit = _root_commit(cwd) or "no-root-commit"
|
|
127
|
+
remote = _primary_remote_url(cwd)
|
|
128
|
+
if remote:
|
|
129
|
+
basis = f"remote:{_normalize_remote(remote)}\n{root_commit}"
|
|
130
|
+
else:
|
|
131
|
+
common = _git_common_dir(cwd)
|
|
132
|
+
basis = f"local:{common}\n{root_commit}"
|
|
133
|
+
return "sha256:" + hashlib.sha256(basis.encode("utf-8")).hexdigest()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_change_contract(contract_dir: Path) -> dict[str, Any]:
|
|
137
|
+
"""Load and validate ``meta/change-context.json`` from a contract dir."""
|
|
138
|
+
contract_dir = Path(contract_dir)
|
|
139
|
+
context_path = contract_dir / CHANGE_CONTEXT_REL
|
|
140
|
+
if not context_path.is_file():
|
|
141
|
+
raise FileNotFoundError(f"change context not found: {context_path}")
|
|
142
|
+
data = json.loads(context_path.read_text(encoding="utf-8-sig"))
|
|
143
|
+
if not isinstance(data, dict):
|
|
144
|
+
raise ValueError(f"change context must be an object: {context_path}")
|
|
145
|
+
lifecycle = data.get("lifecycle")
|
|
146
|
+
if isinstance(lifecycle, dict):
|
|
147
|
+
status = lifecycle.get("status")
|
|
148
|
+
if status is not None and status not in LIFECYCLE_STATUSES:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"invalid lifecycle.status {status!r}; expected one of "
|
|
151
|
+
f"{sorted(LIFECYCLE_STATUSES)}"
|
|
152
|
+
)
|
|
153
|
+
return data
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def contract_layout_kind(contract: dict[str, Any]) -> str:
|
|
157
|
+
"""``split-v1`` when the contract declares a separate runtime root."""
|
|
158
|
+
ownership = contract.get("stateOwnership")
|
|
159
|
+
if isinstance(ownership, dict) and ownership.get("runtimeRoot"):
|
|
160
|
+
return "split-v1"
|
|
161
|
+
return "legacy-colocated"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_runtime_root(main_root: Path, change_name: str, runtime_root: Any) -> Path:
|
|
165
|
+
raw = Path(str(runtime_root or ""))
|
|
166
|
+
if not str(runtime_root or "").strip() or raw.is_absolute():
|
|
167
|
+
raise ValueError("stateOwnership.runtimeRoot must be a project-relative path")
|
|
168
|
+
state_parent = (main_root / ".harness" / "state" / "changes").resolve()
|
|
169
|
+
resolved = (main_root / raw).resolve()
|
|
170
|
+
assert_path_within(resolved, state_parent)
|
|
171
|
+
expected = (state_parent / change_name).resolve()
|
|
172
|
+
if resolved != expected:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"stateOwnership.runtimeRoot must equal "
|
|
175
|
+
f".harness/state/changes/{change_name}"
|
|
176
|
+
)
|
|
177
|
+
return resolved
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resolve_change_layout(
|
|
181
|
+
cwd_hint: Path, change_id_or_dir: str | Path
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
"""Resolve the static/dynamic roots for a change.
|
|
184
|
+
|
|
185
|
+
``cwd_hint`` is any directory inside the checkout (main or linked
|
|
186
|
+
worktree); state always anchors at the main project root. Resolution is
|
|
187
|
+
read-only: legacy files are never moved here.
|
|
188
|
+
"""
|
|
189
|
+
hint = Path(cwd_hint).resolve()
|
|
190
|
+
main_root = resolve_main_project_root(hint)
|
|
191
|
+
worktree_root = resolve_worktree_root(hint)
|
|
192
|
+
|
|
193
|
+
if isinstance(change_id_or_dir, Path):
|
|
194
|
+
contract_dir = change_id_or_dir.resolve()
|
|
195
|
+
change_name = contract_dir.name
|
|
196
|
+
if not contract_dir.is_dir():
|
|
197
|
+
raise FileNotFoundError(f"change contract dir not found: {contract_dir}")
|
|
198
|
+
else:
|
|
199
|
+
change_name = str(change_id_or_dir)
|
|
200
|
+
contract_dir = (main_root / ".harness" / "changes" / change_name).resolve()
|
|
201
|
+
if not contract_dir.is_dir():
|
|
202
|
+
raise FileNotFoundError(f"change not found: {change_name}")
|
|
203
|
+
|
|
204
|
+
contract: dict[str, Any] = {}
|
|
205
|
+
if (contract_dir / CHANGE_CONTEXT_REL).is_file():
|
|
206
|
+
contract = load_change_contract(contract_dir)
|
|
207
|
+
|
|
208
|
+
layout_kind = contract_layout_kind(contract)
|
|
209
|
+
if layout_kind == "split-v1":
|
|
210
|
+
runtime_rel = contract["stateOwnership"]["runtimeRoot"]
|
|
211
|
+
state_root = _resolve_runtime_root(main_root, change_name, runtime_rel)
|
|
212
|
+
else:
|
|
213
|
+
state_root = contract_dir
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"schemaVersion": 1,
|
|
217
|
+
"changeName": change_name,
|
|
218
|
+
"repositoryId": repository_identity(main_root),
|
|
219
|
+
"projectRoot": str(main_root),
|
|
220
|
+
"worktreeRoot": str(worktree_root),
|
|
221
|
+
"contractRoot": str(contract_dir),
|
|
222
|
+
"stateRoot": str(state_root),
|
|
223
|
+
"layout": layout_kind,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def resolve_state_dir_for_contract(
|
|
228
|
+
contract_dir: Path, cwd: Path | None = None
|
|
229
|
+
) -> Path:
|
|
230
|
+
"""Return the dynamic-state root for a change contract dir.
|
|
231
|
+
|
|
232
|
+
split-v1 contracts resolve to ``.harness/state/changes/<id>`` under the
|
|
233
|
+
main project root. Legacy contracts, missing/invalid context files and
|
|
234
|
+
non-standard locations return the contract dir unchanged, so colocated
|
|
235
|
+
readers observe zero behaviour change.
|
|
236
|
+
"""
|
|
237
|
+
contract_dir = Path(contract_dir).resolve()
|
|
238
|
+
try:
|
|
239
|
+
contract = load_change_contract(contract_dir)
|
|
240
|
+
except (FileNotFoundError, ValueError, json.JSONDecodeError, OSError):
|
|
241
|
+
return contract_dir
|
|
242
|
+
if contract_layout_kind(contract) != "split-v1":
|
|
243
|
+
return contract_dir
|
|
244
|
+
main_root = resolve_main_project_root(cwd or contract_dir)
|
|
245
|
+
expected = (main_root / ".harness" / "changes" / contract_dir.name).resolve()
|
|
246
|
+
if contract_dir != expected:
|
|
247
|
+
return contract_dir
|
|
248
|
+
return _resolve_runtime_root(
|
|
249
|
+
main_root, contract_dir.name, contract["stateOwnership"]["runtimeRoot"]
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def assert_path_within(
|
|
254
|
+
path: Path, allowed_root: Path, *, allow_root: bool = False
|
|
255
|
+
) -> Path:
|
|
256
|
+
"""Resolve ``path`` and require it to stay inside ``allowed_root``.
|
|
257
|
+
|
|
258
|
+
Rejects empty paths, parents, sibling-prefix attacks and symlink escapes.
|
|
259
|
+
Returns the resolved path on success.
|
|
260
|
+
"""
|
|
261
|
+
raw = str(path)
|
|
262
|
+
if not raw or not raw.strip():
|
|
263
|
+
raise ValueError("empty path is not allowed")
|
|
264
|
+
root = Path(allowed_root).resolve()
|
|
265
|
+
resolved = Path(path).resolve()
|
|
266
|
+
if resolved == root:
|
|
267
|
+
if allow_root:
|
|
268
|
+
return resolved
|
|
269
|
+
raise ValueError(f"path is the allowed root itself: {resolved}")
|
|
270
|
+
if not resolved.is_relative_to(root):
|
|
271
|
+
raise ValueError(f"path escapes allowed root: {resolved} not within {root}")
|
|
272
|
+
return resolved
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cmd_resolve_layout(args: argparse.Namespace) -> int:
|
|
276
|
+
layout = resolve_change_layout(Path.cwd(), args.change)
|
|
277
|
+
sys.stdout.write(json.dumps(layout, ensure_ascii=False, indent=2) + "\n")
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_repository_id(args: argparse.Namespace) -> int:
|
|
282
|
+
identity = repository_identity(Path.cwd())
|
|
283
|
+
if args.json:
|
|
284
|
+
sys.stdout.write(
|
|
285
|
+
json.dumps({"ok": True, "repositoryId": identity}, ensure_ascii=False)
|
|
286
|
+
+ "\n"
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
sys.stdout.write(identity + "\n")
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
294
|
+
parser = argparse.ArgumentParser(prog="harness_paths.py")
|
|
295
|
+
sub = parser.add_subparsers(dest="command_name", required=True)
|
|
296
|
+
|
|
297
|
+
p_layout = sub.add_parser("resolve-layout")
|
|
298
|
+
p_layout.add_argument("--change", required=True)
|
|
299
|
+
p_layout.set_defaults(func=cmd_resolve_layout)
|
|
300
|
+
|
|
301
|
+
p_id = sub.add_parser("repository-id")
|
|
302
|
+
p_id.add_argument("--json", action="store_true")
|
|
303
|
+
p_id.set_defaults(func=cmd_repository_id)
|
|
304
|
+
|
|
305
|
+
return parser
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def main(argv: list[str] | None = None) -> int:
|
|
309
|
+
parser = build_parser()
|
|
310
|
+
args = parser.parse_args(argv)
|
|
311
|
+
return int(args.func(args))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
raise SystemExit(main())
|
|
@@ -727,6 +727,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
727
727
|
p_validate.add_argument("--project", required=True, type=Path)
|
|
728
728
|
p_validate.add_argument("--json", action="store_true")
|
|
729
729
|
|
|
730
|
+
p_resolve = sub.add_parser(
|
|
731
|
+
"resolve", help="resolve command template by key (runtime placeholder substitution)"
|
|
732
|
+
)
|
|
733
|
+
p_resolve.add_argument("--project", required=True, type=Path)
|
|
734
|
+
p_resolve.add_argument("--key", required=True, help="command key in profile.commands")
|
|
735
|
+
p_resolve.add_argument(
|
|
736
|
+
"--test-classes",
|
|
737
|
+
default=None,
|
|
738
|
+
help="comma-separated test classes substituting {testClasses}",
|
|
739
|
+
)
|
|
740
|
+
p_resolve.add_argument(
|
|
741
|
+
"--modules",
|
|
742
|
+
default=None,
|
|
743
|
+
help="comma-separated modules substituting {modules}",
|
|
744
|
+
)
|
|
745
|
+
p_resolve.add_argument("--json", action="store_true")
|
|
746
|
+
|
|
730
747
|
p_migrate = sub.add_parser("migrate", help="migrate v1 → v2")
|
|
731
748
|
p_migrate.add_argument("--project", required=True, type=Path)
|
|
732
749
|
p_migrate.add_argument("--apply", action="store_true", help="apply (default: dry-run)")
|
|
@@ -757,6 +774,34 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
757
774
|
payload = {"ok": not issues, "issues": issues}
|
|
758
775
|
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
759
776
|
return 0 if payload["ok"] else 1
|
|
777
|
+
if args.command == "resolve":
|
|
778
|
+
project = args.project.resolve()
|
|
779
|
+
profile = load_profile(project)
|
|
780
|
+
if profile is None:
|
|
781
|
+
sys.stdout.write(
|
|
782
|
+
json.dumps({"ok": False, "error": "profile missing"}, ensure_ascii=False) + "\n"
|
|
783
|
+
)
|
|
784
|
+
return 1
|
|
785
|
+
test_classes = (
|
|
786
|
+
[item for item in args.test_classes.split(",") if item]
|
|
787
|
+
if args.test_classes
|
|
788
|
+
else None
|
|
789
|
+
)
|
|
790
|
+
modules = (
|
|
791
|
+
[item for item in args.modules.split(",") if item] if args.modules else None
|
|
792
|
+
)
|
|
793
|
+
try:
|
|
794
|
+
resolved = resolve_command(
|
|
795
|
+
profile, args.key, test_classes=test_classes, modules=modules
|
|
796
|
+
)
|
|
797
|
+
except KeyError as exc:
|
|
798
|
+
sys.stdout.write(
|
|
799
|
+
json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False) + "\n"
|
|
800
|
+
)
|
|
801
|
+
return 1
|
|
802
|
+
payload = {"ok": True, "key": args.key, **resolved}
|
|
803
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
804
|
+
return 0
|
|
760
805
|
if args.command == "migrate":
|
|
761
806
|
result = migrate(args.project, dry_run=not args.apply)
|
|
762
807
|
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Harness review findings / fixback dispositions sidecar.
|
|
3
|
+
|
|
4
|
+
Structured source of truth for review output; Markdown reports are a human
|
|
5
|
+
projection of these sidecars, never the counting source.
|
|
6
|
+
|
|
7
|
+
Files (under the change state root):
|
|
8
|
+
reports/review/review-findings.json
|
|
9
|
+
reports/review/fixback-dispositions.json
|
|
10
|
+
|
|
11
|
+
Python 3.10+, stdlib only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import uuid
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
27
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
28
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
29
|
+
|
|
30
|
+
import harness_paths # noqa: E402
|
|
31
|
+
|
|
32
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
33
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
34
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
35
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
SEVERITIES = {"RED", "YELLOW", "OK"}
|
|
38
|
+
DISPOSITIONS = {"OPEN", "FIXED", "ACCEPTED_RISK", "DEFERRED", "UNKNOWN"}
|
|
39
|
+
FINDINGS_REL = Path("reports") / "review" / "review-findings.json"
|
|
40
|
+
DISPOSITIONS_REL = Path("reports") / "review" / "fixback-dispositions.json"
|
|
41
|
+
_REQUIRED_FINDING_FIELDS = ("dimension", "severity", "path", "line", "title")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_json_atomic(path: Path, data: Any) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
47
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
48
|
+
try:
|
|
49
|
+
tmp.write_text(text, encoding="utf-8", newline="\n")
|
|
50
|
+
os.replace(tmp, path)
|
|
51
|
+
except BaseException:
|
|
52
|
+
tmp.unlink(missing_ok=True)
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_json(path: Path) -> Any:
|
|
57
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _state_dir(change_dir: Path) -> Path:
|
|
61
|
+
return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def findings_path(change_dir: Path) -> Path:
|
|
65
|
+
return _state_dir(change_dir) / FINDINGS_REL
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def dispositions_path(change_dir: Path) -> Path:
|
|
69
|
+
return _state_dir(change_dir) / DISPOSITIONS_REL
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalize_title(title: str) -> str:
|
|
73
|
+
return re.sub(r"\s+", " ", title.strip().lower())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def stable_finding_id(
|
|
77
|
+
run_id: str, dimension: str, path: str, line: int, title: str
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Stable finding identity (run + dimension + canonical path + line + title)."""
|
|
80
|
+
canonical_path = str(path).replace("\\", "/").strip("/").lower()
|
|
81
|
+
basis = (
|
|
82
|
+
f"{run_id}|{dimension.strip().lower()}|{canonical_path}|{int(line)}|"
|
|
83
|
+
f"{_normalize_title(title)}"
|
|
84
|
+
)
|
|
85
|
+
return "f-" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:16]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_findings(doc: Any) -> list[str]:
|
|
89
|
+
problems: list[str] = []
|
|
90
|
+
if not isinstance(doc, dict):
|
|
91
|
+
return ["findings document must be an object"]
|
|
92
|
+
if not isinstance(doc.get("runId"), str) or not doc["runId"].strip():
|
|
93
|
+
problems.append("runId is required")
|
|
94
|
+
findings = doc.get("findings")
|
|
95
|
+
if not isinstance(findings, list):
|
|
96
|
+
problems.append("findings must be a list")
|
|
97
|
+
return problems
|
|
98
|
+
for index, finding in enumerate(findings):
|
|
99
|
+
if not isinstance(finding, dict):
|
|
100
|
+
problems.append(f"findings[{index}] must be an object")
|
|
101
|
+
continue
|
|
102
|
+
for field in _REQUIRED_FINDING_FIELDS:
|
|
103
|
+
if field not in finding:
|
|
104
|
+
problems.append(f"findings[{index}].{field} is required")
|
|
105
|
+
severity = finding.get("severity")
|
|
106
|
+
if severity is not None and severity not in SEVERITIES:
|
|
107
|
+
problems.append(
|
|
108
|
+
f"findings[{index}].severity must be one of {sorted(SEVERITIES)}"
|
|
109
|
+
)
|
|
110
|
+
line = finding.get("line")
|
|
111
|
+
if line is not None and (not isinstance(line, int) or line < 0):
|
|
112
|
+
problems.append(f"findings[{index}].line must be a non-negative int")
|
|
113
|
+
return problems
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_findings(change_dir: Path, doc: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
problems = validate_findings(doc)
|
|
118
|
+
if problems:
|
|
119
|
+
return {"ok": False, "code": "FINDINGS_INVALID", "problems": problems}
|
|
120
|
+
run_id = doc["runId"]
|
|
121
|
+
assigned: list[dict[str, Any]] = []
|
|
122
|
+
seen: set[str] = set()
|
|
123
|
+
for finding in doc["findings"]:
|
|
124
|
+
fid = stable_finding_id(
|
|
125
|
+
run_id,
|
|
126
|
+
finding["dimension"],
|
|
127
|
+
finding["path"],
|
|
128
|
+
finding["line"],
|
|
129
|
+
finding["title"],
|
|
130
|
+
)
|
|
131
|
+
suffix = 2
|
|
132
|
+
unique = fid
|
|
133
|
+
while unique in seen:
|
|
134
|
+
unique = f"{fid}-{suffix}"
|
|
135
|
+
suffix += 1
|
|
136
|
+
seen.add(unique)
|
|
137
|
+
entry = dict(finding)
|
|
138
|
+
entry["id"] = unique
|
|
139
|
+
assigned.append(entry)
|
|
140
|
+
payload = {
|
|
141
|
+
"schemaVersion": 1,
|
|
142
|
+
"runId": run_id,
|
|
143
|
+
"changeName": doc.get("changeName") or Path(change_dir).name,
|
|
144
|
+
"findings": assigned,
|
|
145
|
+
}
|
|
146
|
+
out = findings_path(change_dir)
|
|
147
|
+
_write_json_atomic(out, payload)
|
|
148
|
+
return {"ok": True, "code": "FINDINGS_WRITTEN", "path": str(out),
|
|
149
|
+
"count": len(assigned)}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _load_findings(change_dir: Path) -> dict[str, Any] | None:
|
|
153
|
+
path = findings_path(change_dir)
|
|
154
|
+
if not path.is_file():
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
data = _read_json(path)
|
|
158
|
+
except (OSError, json.JSONDecodeError):
|
|
159
|
+
return None
|
|
160
|
+
return data if isinstance(data, dict) else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate_dispositions(
|
|
164
|
+
doc: Any, known_ids: set[str]
|
|
165
|
+
) -> list[str]:
|
|
166
|
+
problems: list[str] = []
|
|
167
|
+
if not isinstance(doc, dict):
|
|
168
|
+
return ["dispositions document must be an object"]
|
|
169
|
+
dispositions = doc.get("dispositions")
|
|
170
|
+
if not isinstance(dispositions, list):
|
|
171
|
+
return ["dispositions must be a list"]
|
|
172
|
+
for index, item in enumerate(dispositions):
|
|
173
|
+
if not isinstance(item, dict):
|
|
174
|
+
problems.append(f"dispositions[{index}] must be an object")
|
|
175
|
+
continue
|
|
176
|
+
fid = item.get("findingId")
|
|
177
|
+
if not isinstance(fid, str) or not fid.strip():
|
|
178
|
+
problems.append(f"dispositions[{index}].findingId is required")
|
|
179
|
+
elif fid not in known_ids:
|
|
180
|
+
problems.append(f"dispositions[{index}].findingId unknown: {fid}")
|
|
181
|
+
value = item.get("disposition")
|
|
182
|
+
if value not in DISPOSITIONS:
|
|
183
|
+
problems.append(
|
|
184
|
+
f"dispositions[{index}].disposition must be one of "
|
|
185
|
+
f"{sorted(DISPOSITIONS)}"
|
|
186
|
+
)
|
|
187
|
+
return problems
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def write_dispositions(change_dir: Path, doc: dict[str, Any]) -> dict[str, Any]:
|
|
191
|
+
findings_doc = _load_findings(change_dir)
|
|
192
|
+
known_ids = {
|
|
193
|
+
f.get("id") for f in (findings_doc or {}).get("findings", [])
|
|
194
|
+
if isinstance(f, dict)
|
|
195
|
+
}
|
|
196
|
+
problems = validate_dispositions(doc, known_ids)
|
|
197
|
+
if problems:
|
|
198
|
+
return {"ok": False, "code": "DISPOSITIONS_INVALID", "problems": problems}
|
|
199
|
+
payload = {
|
|
200
|
+
"schemaVersion": 1,
|
|
201
|
+
"runId": doc.get("runId"),
|
|
202
|
+
"dispositions": doc["dispositions"],
|
|
203
|
+
}
|
|
204
|
+
out = dispositions_path(change_dir)
|
|
205
|
+
_write_json_atomic(out, payload)
|
|
206
|
+
return {"ok": True, "code": "DISPOSITIONS_WRITTEN", "path": str(out)}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def status(change_dir: Path) -> dict[str, Any]:
|
|
210
|
+
findings_doc = _load_findings(change_dir)
|
|
211
|
+
if findings_doc is None:
|
|
212
|
+
return {
|
|
213
|
+
"ok": True,
|
|
214
|
+
"code": "NO_FINDINGS",
|
|
215
|
+
"counts": {"RED": 0, "YELLOW": 0, "OK": 0},
|
|
216
|
+
"dispositions": {},
|
|
217
|
+
"items": [],
|
|
218
|
+
}
|
|
219
|
+
dispositions_doc: dict[str, Any] = {}
|
|
220
|
+
dpath = dispositions_path(change_dir)
|
|
221
|
+
if dpath.is_file():
|
|
222
|
+
try:
|
|
223
|
+
loaded = _read_json(dpath)
|
|
224
|
+
if isinstance(loaded, dict):
|
|
225
|
+
dispositions_doc = loaded
|
|
226
|
+
except (OSError, json.JSONDecodeError):
|
|
227
|
+
dispositions_doc = {}
|
|
228
|
+
by_id = {
|
|
229
|
+
item.get("findingId"): item
|
|
230
|
+
for item in dispositions_doc.get("dispositions", [])
|
|
231
|
+
if isinstance(item, dict)
|
|
232
|
+
}
|
|
233
|
+
counts = {"RED": 0, "YELLOW": 0, "OK": 0}
|
|
234
|
+
disposition_counts: dict[str, int] = {}
|
|
235
|
+
items: list[dict[str, Any]] = []
|
|
236
|
+
for finding in findings_doc.get("findings", []):
|
|
237
|
+
severity = finding.get("severity")
|
|
238
|
+
if severity in counts:
|
|
239
|
+
counts[severity] += 1
|
|
240
|
+
entry = by_id.get(finding.get("id"))
|
|
241
|
+
disposition = entry.get("disposition") if entry else None
|
|
242
|
+
if disposition not in DISPOSITIONS:
|
|
243
|
+
disposition = "UNKNOWN"
|
|
244
|
+
disposition_counts[disposition] = disposition_counts.get(disposition, 0) + 1
|
|
245
|
+
items.append(
|
|
246
|
+
{
|
|
247
|
+
"id": finding.get("id"),
|
|
248
|
+
"severity": severity,
|
|
249
|
+
"path": finding.get("path"),
|
|
250
|
+
"line": finding.get("line"),
|
|
251
|
+
"title": finding.get("title"),
|
|
252
|
+
"disposition": disposition,
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
return {
|
|
256
|
+
"ok": True,
|
|
257
|
+
"code": "STATUS",
|
|
258
|
+
"runId": findings_doc.get("runId"),
|
|
259
|
+
"counts": counts,
|
|
260
|
+
"dispositions": disposition_counts,
|
|
261
|
+
"items": items,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------- CLI
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _emit(payload: Any, *, as_json: bool) -> int:
|
|
269
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
270
|
+
return 0 if payload.get("ok") else 1
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def cmd_validate_findings(args: argparse.Namespace) -> int:
|
|
274
|
+
doc = _read_json(Path(args.input))
|
|
275
|
+
problems = validate_findings(doc)
|
|
276
|
+
payload = {"ok": not problems, "problems": problems}
|
|
277
|
+
return _emit(payload, as_json=True)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_write_findings(args: argparse.Namespace) -> int:
|
|
281
|
+
doc = _read_json(Path(args.input))
|
|
282
|
+
return _emit(write_findings(Path(args.change_dir), doc), as_json=True)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def cmd_write_dispositions(args: argparse.Namespace) -> int:
|
|
286
|
+
doc = _read_json(Path(args.input))
|
|
287
|
+
return _emit(write_dispositions(Path(args.change_dir), doc), as_json=True)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
291
|
+
return _emit(status(Path(args.change_dir)), as_json=True)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
295
|
+
parser = argparse.ArgumentParser(prog="harness_review.py")
|
|
296
|
+
sub = parser.add_subparsers(dest="command_name", required=True)
|
|
297
|
+
|
|
298
|
+
p_validate = sub.add_parser("validate-findings")
|
|
299
|
+
p_validate.add_argument("--input", required=True)
|
|
300
|
+
p_validate.set_defaults(func=cmd_validate_findings)
|
|
301
|
+
|
|
302
|
+
p_findings = sub.add_parser("write-findings")
|
|
303
|
+
p_findings.add_argument("--change-dir", required=True)
|
|
304
|
+
p_findings.add_argument("--input", required=True)
|
|
305
|
+
p_findings.set_defaults(func=cmd_write_findings)
|
|
306
|
+
|
|
307
|
+
p_dispositions = sub.add_parser("write-dispositions")
|
|
308
|
+
p_dispositions.add_argument("--change-dir", required=True)
|
|
309
|
+
p_dispositions.add_argument("--input", required=True)
|
|
310
|
+
p_dispositions.set_defaults(func=cmd_write_dispositions)
|
|
311
|
+
|
|
312
|
+
p_status = sub.add_parser("status")
|
|
313
|
+
p_status.add_argument("--change-dir", required=True)
|
|
314
|
+
p_status.set_defaults(func=cmd_status)
|
|
315
|
+
|
|
316
|
+
return parser
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def main(argv: list[str] | None = None) -> int:
|
|
320
|
+
parser = build_parser()
|
|
321
|
+
args = parser.parse_args(argv)
|
|
322
|
+
return int(args.func(args))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__":
|
|
326
|
+
raise SystemExit(main())
|