From c5c11147f01a9ab3a89e9fbd84c0f5f101771026 Mon Sep 17 00:00:00 2001 From: Matt Faltyn Date: Mon, 27 Jul 2026 18:28:22 +0200 Subject: [PATCH] Rewrite IntentCI as lean Mac-first v2 --- .github/workflows/ci.yml | 65 +- .github/workflows/release.yml | 132 +- .gitignore | 1 + .intentci.yaml | 10 + .intentci/.gitignore | 4 - .intentci/config.yaml | 48 - .intentci/requirements/BUILD-001.md | 76 - .intentci/requirements/PRIVACY-001.md | 44 - CHANGELOG.md | 86 +- README.md | 190 +- cmd/intentci/main.go | 4 +- docs/CONTRIBUTING.md | 31 +- docs/SECURITY.md | 61 +- docs/acceptance-v0.1.md | 29 - docs/acceptance-v0.2.md | 13 - docs/acceptance-v0.3.md | 18 - docs/acceptance-v0.4.md | 18 - docs/acceptance-v1.md | 70 - docs/configuration.md | 60 - docs/migration-v0-to-v1.md | 80 - docs/migration-v1-to-v2.md | 49 + docs/migration-v1.0-to-v1.1.md | 58 - docs/performance-v1.md | 25 - docs/provider-protocol-v1.md | 92 - docs/releases/v1.1.0.md | 30 - docs/releases/v1.1.1.md | 14 - docs/releases/v2.0.0.md | 12 + docs/roadmap.md | 47 - docs/v0.1.md | 74 - docs/v0.2.md | 40 - docs/v0.3.md | 42 - docs/v0.4.md | 47 - docs/v1-conformance.md | 59 - docs/v1.md | 40 - examples/github-actions/intentci.yml | 22 - examples/go/.intentci.yaml | 9 + examples/go/.intentci/.gitignore | 4 - examples/go/.intentci/config.yaml | 10 - .../go/.intentci/requirements/REQ-GO-001.md | 35 - examples/go/README.md | 10 +- examples/java/.gitignore | 3 - examples/java/.intentci/config.yaml | 10 - .../.intentci/requirements/REQ-JAVA-001.md | 35 - examples/java/README.md | 12 - .../src/main/java/example/Calculator.java | 9 - .../src/test/java/example/CalculatorTest.java | 10 - examples/python/.gitignore | 4 - examples/python/.intentci/.gitignore | 4 - examples/python/.intentci/config.yaml | 10 - .../.intentci/requirements/REQ-PYTHON-001.md | 36 - examples/python/README.md | 9 - examples/python/calculator.py | 3 - examples/python/test_calculator.py | 12 - examples/rust/.gitignore | 3 - examples/rust/.intentci/config.yaml | 10 - .../.intentci/requirements/REQ-RUST-001.md | 36 - examples/rust/Cargo.lock | 7 - examples/rust/Cargo.toml | 7 - examples/rust/README.md | 7 - examples/rust/src/lib.rs | 14 - examples/typescript/.gitignore | 4 - examples/typescript/.intentci/config.yaml | 10 - .../requirements/REQ-TYPESCRIPT-001.md | 36 - examples/typescript/README.md | 8 - examples/typescript/package-lock.json | 27 - examples/typescript/package.json | 12 - examples/typescript/src/calculator.ts | 3 - examples/typescript/test/calculator.test.mjs | 8 - examples/typescript/tsconfig.json | 11 - fixtures/failing-typescript/README.md | 4 - fixtures/failing-typescript/package-lock.json | 27 - fixtures/failing-typescript/package.json | 11 - fixtures/failing-typescript/src/calculator.ts | 3 - .../test/calculator.test.mjs | 8 - fixtures/failing-typescript/tsconfig.json | 11 - fixtures/repair-go/.intentci/.gitignore | 4 - fixtures/repair-go/.intentci/config.yaml | 29 - .../.intentci/requirements/REQ-REPAIR-001.md | 45 - fixtures/repair-go/README.md | 5 - fixtures/repair-go/counter.go | 6 - fixtures/repair-go/counter_test.go | 9 - fixtures/repair-go/go.mod | 3 - fixtures/repair-go/repair/counter.fixed | 6 - fixtures/repair-go/repair/fake-agent.sh | 5 - fixtures/reports/junit-failure.xml | 9 - fixtures/reports/result.json | 6 - fixtures/reports/sarif-error.json | 22 - go.mod | 11 +- go.sum | 18 - internal/app/app.go | 314 ++ internal/app/app_test.go | 301 ++ internal/cli/cli_coverage_test.go | 140 - internal/cli/cli_final_internal_test.go | 125 - internal/cli/cli_internal_test.go | 96 - internal/cli/cli_more_internal_test.go | 180 -- internal/cli/cli_test.go | 90 - .../cli/cli_v1_completion_internal_test.go | 353 -- internal/cli/commands.go | 607 ---- internal/cli/root.go | 91 - internal/compiler/benchmark_test.go | 75 - internal/compiler/compiler.go | 755 ----- internal/compiler/compiler_coverage_test.go | 223 -- internal/compiler/compiler_internal_test.go | 129 - internal/compiler/compiler_test.go | 138 - .../compiler/compiler_v1_internal_test.go | 235 -- internal/compiler/fuzz_test.go | 41 - internal/compiler/mutation_internal_test.go | 260 -- internal/config/config.go | 415 +-- internal/config/config_coverage_test.go | 115 - internal/config/config_test.go | 129 +- internal/config/config_v1_test.go | 123 - internal/evidence/bundle.go | 640 ---- internal/evidence/bundle_coverage_test.go | 96 - internal/evidence/bundle_internal_test.go | 61 - internal/evidence/bundle_test.go | 33 - internal/evidence/bundle_v1_internal_test.go | 498 --- internal/evidence/bundle_v1_test.go | 198 -- internal/evidence/fuzz_test.go | 60 - internal/executor/benchmark_test.go | 58 - internal/executor/executor.go | 1026 ------ internal/executor/executor_coverage_test.go | 97 - internal/executor/executor_internal_test.go | 63 - internal/executor/executor_test.go | 100 - internal/executor/executor_v1_edges_test.go | 549 ---- .../executor/executor_v1_internal_test.go | 216 -- internal/exitcode/exitcode.go | 16 - internal/exitcode/exitcode_test.go | 13 - internal/git/git.go | 380 --- internal/git/git_coverage_test.go | 62 - internal/git/git_internal_test.go | 196 -- internal/git/git_test.go | 39 - internal/git/git_v1_internal_test.go | 166 - internal/git/git_v1_test.go | 105 - internal/impact/benchmark_test.go | 30 - internal/impact/fuzz_test.go | 26 - internal/impact/impact.go | 258 -- internal/impact/impact_coverage_test.go | 59 - internal/impact/impact_test.go | 31 - internal/impact/impact_v1_test.go | 66 - internal/initcmd/init.go | 238 -- internal/initcmd/init_coverage_test.go | 68 - internal/initcmd/init_internal_test.go | 50 - internal/initcmd/init_test.go | 31 - internal/ir/fuzz_test.go | 45 - internal/ir/ir.go | 269 -- internal/ir/ir_coverage_test.go | 62 - internal/ir/ir_internal_test.go | 83 - internal/ir/ir_test.go | 30 - internal/parser/parser.go | 648 ---- internal/parser/parser_coverage_test.go | 237 -- internal/parser/parser_internal_test.go | 57 - internal/parser/parser_test.go | 132 - internal/parser/parser_v1_internal_test.go | 98 - internal/parser/scalar_internal_test.go | 25 - internal/parser/yaml_scalar_test.go | 88 - internal/provider/boundary.go | 89 - internal/provider/command.go | 164 - internal/provider/external.go | 111 - internal/provider/external_v1_test.go | 108 - internal/provider/gitdiff.go | 135 - internal/provider/json_provider.go | 245 -- internal/provider/junit.go | 221 -- internal/provider/manual.go | 28 - internal/provider/process.go | 63 - internal/provider/provider.go | 237 -- internal/provider/provider_coverage_test.go | 369 --- internal/provider/provider_internal_test.go | 9 - internal/provider/provider_test.go | 120 - .../provider/provider_v1_internal_test.go | 241 -- internal/provider/providers_v1_test.go | 185 -- internal/provider/sarif.go | 271 -- internal/repair/repair.go | 447 --- internal/repair/repair_coverage_test.go | 245 -- internal/repair/repair_internal_test.go | 166 - internal/repair/repair_test.go | 176 - internal/repair/repair_v1_internal_test.go | 355 -- internal/repo/repo.go | 74 + internal/repo/repo_test.go | 106 + internal/report/report.go | 228 -- internal/report/report_coverage_test.go | 74 - internal/report/report_test.go | 53 - internal/report/report_v1_test.go | 94 - internal/security/fuzz_test.go | 29 - internal/security/security.go | 223 -- internal/security/security_coverage_test.go | 34 - internal/security/security_test.go | 25 - .../security/security_v1_internal_test.go | 63 - internal/security/security_v1_test.go | 71 - internal/verdict/benchmark_test.go | 26 - internal/verdict/fuzz_test.go | 29 - internal/verdict/mutation_internal_test.go | 57 - internal/verdict/verdict.go | 318 -- internal/verdict/verdict_coverage_test.go | 129 - internal/verdict/verdict_test.go | 80 - internal/verdict/verdict_v1_test.go | 92 - internal/verify/verify.go | 306 -- internal/verify/verify_coverage_test.go | 72 - internal/verify/verify_internal_test.go | 36 - internal/verify/verify_test.go | 42 - internal/verify/verify_v1_internal_test.go | 310 -- internal/version/version.go | 6 +- internal/version/version_internal_test.go | 16 - internal/version/version_test.go | 25 +- pkg/schema/evidence.schema.json | 70 - pkg/schema/ir.schema.json | 17 - pkg/schema/plan.schema.json | 38 - pkg/schema/repair.schema.json | 38 - pkg/schema/report.schema.json | 31 - pkg/schema/requirement.schema.json | 182 -- pkg/schema/schema.go | 103 - pkg/schema/schema_internal_test.go | 36 - pkg/schema/schema_test.go | 23 - pkg/schema/verdict.schema.json | 7 - scripts/check-coverage.sh | 15 - scripts/check_examples.sh | 35 - scripts/check_fuzz.sh | 22 - scripts/check_mutation.sh | 50 - scripts/check_schemas.sh | 17 - scripts/cross_compile.sh | 22 - scripts/package_release.sh | 33 - scripts/record_performance.sh | 41 - scripts/validate_v1_release.sh | 37 - tests/acceptance/v1_acceptance_test.go | 576 ---- tests/performance/performance_test.go | 20 - v1.md | 2878 ----------------- 225 files changed, 1189 insertions(+), 24304 deletions(-) create mode 100644 .intentci.yaml delete mode 100644 .intentci/.gitignore delete mode 100644 .intentci/config.yaml delete mode 100644 .intentci/requirements/BUILD-001.md delete mode 100644 .intentci/requirements/PRIVACY-001.md delete mode 100644 docs/acceptance-v0.1.md delete mode 100644 docs/acceptance-v0.2.md delete mode 100644 docs/acceptance-v0.3.md delete mode 100644 docs/acceptance-v0.4.md delete mode 100644 docs/acceptance-v1.md delete mode 100644 docs/configuration.md delete mode 100644 docs/migration-v0-to-v1.md create mode 100644 docs/migration-v1-to-v2.md delete mode 100644 docs/migration-v1.0-to-v1.1.md delete mode 100644 docs/performance-v1.md delete mode 100644 docs/provider-protocol-v1.md delete mode 100644 docs/releases/v1.1.0.md delete mode 100644 docs/releases/v1.1.1.md create mode 100644 docs/releases/v2.0.0.md delete mode 100644 docs/roadmap.md delete mode 100644 docs/v0.1.md delete mode 100644 docs/v0.2.md delete mode 100644 docs/v0.3.md delete mode 100644 docs/v0.4.md delete mode 100644 docs/v1-conformance.md delete mode 100644 docs/v1.md delete mode 100644 examples/github-actions/intentci.yml create mode 100644 examples/go/.intentci.yaml delete mode 100644 examples/go/.intentci/.gitignore delete mode 100644 examples/go/.intentci/config.yaml delete mode 100644 examples/go/.intentci/requirements/REQ-GO-001.md delete mode 100644 examples/java/.gitignore delete mode 100644 examples/java/.intentci/config.yaml delete mode 100644 examples/java/.intentci/requirements/REQ-JAVA-001.md delete mode 100644 examples/java/README.md delete mode 100644 examples/java/src/main/java/example/Calculator.java delete mode 100644 examples/java/src/test/java/example/CalculatorTest.java delete mode 100644 examples/python/.gitignore delete mode 100644 examples/python/.intentci/.gitignore delete mode 100644 examples/python/.intentci/config.yaml delete mode 100644 examples/python/.intentci/requirements/REQ-PYTHON-001.md delete mode 100644 examples/python/README.md delete mode 100644 examples/python/calculator.py delete mode 100644 examples/python/test_calculator.py delete mode 100644 examples/rust/.gitignore delete mode 100644 examples/rust/.intentci/config.yaml delete mode 100644 examples/rust/.intentci/requirements/REQ-RUST-001.md delete mode 100644 examples/rust/Cargo.lock delete mode 100644 examples/rust/Cargo.toml delete mode 100644 examples/rust/README.md delete mode 100644 examples/rust/src/lib.rs delete mode 100644 examples/typescript/.gitignore delete mode 100644 examples/typescript/.intentci/config.yaml delete mode 100644 examples/typescript/.intentci/requirements/REQ-TYPESCRIPT-001.md delete mode 100644 examples/typescript/README.md delete mode 100644 examples/typescript/package-lock.json delete mode 100644 examples/typescript/package.json delete mode 100644 examples/typescript/src/calculator.ts delete mode 100644 examples/typescript/test/calculator.test.mjs delete mode 100644 examples/typescript/tsconfig.json delete mode 100644 fixtures/failing-typescript/README.md delete mode 100644 fixtures/failing-typescript/package-lock.json delete mode 100644 fixtures/failing-typescript/package.json delete mode 100644 fixtures/failing-typescript/src/calculator.ts delete mode 100644 fixtures/failing-typescript/test/calculator.test.mjs delete mode 100644 fixtures/failing-typescript/tsconfig.json delete mode 100644 fixtures/repair-go/.intentci/.gitignore delete mode 100644 fixtures/repair-go/.intentci/config.yaml delete mode 100644 fixtures/repair-go/.intentci/requirements/REQ-REPAIR-001.md delete mode 100644 fixtures/repair-go/README.md delete mode 100644 fixtures/repair-go/counter.go delete mode 100644 fixtures/repair-go/counter_test.go delete mode 100644 fixtures/repair-go/go.mod delete mode 100644 fixtures/repair-go/repair/counter.fixed delete mode 100755 fixtures/repair-go/repair/fake-agent.sh delete mode 100644 fixtures/reports/junit-failure.xml delete mode 100644 fixtures/reports/result.json delete mode 100644 fixtures/reports/sarif-error.json create mode 100644 internal/app/app.go create mode 100644 internal/app/app_test.go delete mode 100644 internal/cli/cli_coverage_test.go delete mode 100644 internal/cli/cli_final_internal_test.go delete mode 100644 internal/cli/cli_internal_test.go delete mode 100644 internal/cli/cli_more_internal_test.go delete mode 100644 internal/cli/cli_test.go delete mode 100644 internal/cli/cli_v1_completion_internal_test.go delete mode 100644 internal/cli/commands.go delete mode 100644 internal/cli/root.go delete mode 100644 internal/compiler/benchmark_test.go delete mode 100644 internal/compiler/compiler.go delete mode 100644 internal/compiler/compiler_coverage_test.go delete mode 100644 internal/compiler/compiler_internal_test.go delete mode 100644 internal/compiler/compiler_test.go delete mode 100644 internal/compiler/compiler_v1_internal_test.go delete mode 100644 internal/compiler/fuzz_test.go delete mode 100644 internal/compiler/mutation_internal_test.go delete mode 100644 internal/config/config_coverage_test.go delete mode 100644 internal/config/config_v1_test.go delete mode 100644 internal/evidence/bundle.go delete mode 100644 internal/evidence/bundle_coverage_test.go delete mode 100644 internal/evidence/bundle_internal_test.go delete mode 100644 internal/evidence/bundle_test.go delete mode 100644 internal/evidence/bundle_v1_internal_test.go delete mode 100644 internal/evidence/bundle_v1_test.go delete mode 100644 internal/evidence/fuzz_test.go delete mode 100644 internal/executor/benchmark_test.go delete mode 100644 internal/executor/executor.go delete mode 100644 internal/executor/executor_coverage_test.go delete mode 100644 internal/executor/executor_internal_test.go delete mode 100644 internal/executor/executor_test.go delete mode 100644 internal/executor/executor_v1_edges_test.go delete mode 100644 internal/executor/executor_v1_internal_test.go delete mode 100644 internal/exitcode/exitcode.go delete mode 100644 internal/exitcode/exitcode_test.go delete mode 100644 internal/git/git.go delete mode 100644 internal/git/git_coverage_test.go delete mode 100644 internal/git/git_internal_test.go delete mode 100644 internal/git/git_test.go delete mode 100644 internal/git/git_v1_internal_test.go delete mode 100644 internal/git/git_v1_test.go delete mode 100644 internal/impact/benchmark_test.go delete mode 100644 internal/impact/fuzz_test.go delete mode 100644 internal/impact/impact.go delete mode 100644 internal/impact/impact_coverage_test.go delete mode 100644 internal/impact/impact_test.go delete mode 100644 internal/impact/impact_v1_test.go delete mode 100644 internal/initcmd/init.go delete mode 100644 internal/initcmd/init_coverage_test.go delete mode 100644 internal/initcmd/init_internal_test.go delete mode 100644 internal/initcmd/init_test.go delete mode 100644 internal/ir/fuzz_test.go delete mode 100644 internal/ir/ir.go delete mode 100644 internal/ir/ir_coverage_test.go delete mode 100644 internal/ir/ir_internal_test.go delete mode 100644 internal/ir/ir_test.go delete mode 100644 internal/parser/parser.go delete mode 100644 internal/parser/parser_coverage_test.go delete mode 100644 internal/parser/parser_internal_test.go delete mode 100644 internal/parser/parser_test.go delete mode 100644 internal/parser/parser_v1_internal_test.go delete mode 100644 internal/parser/scalar_internal_test.go delete mode 100644 internal/parser/yaml_scalar_test.go delete mode 100644 internal/provider/boundary.go delete mode 100644 internal/provider/command.go delete mode 100644 internal/provider/external.go delete mode 100644 internal/provider/external_v1_test.go delete mode 100644 internal/provider/gitdiff.go delete mode 100644 internal/provider/json_provider.go delete mode 100644 internal/provider/junit.go delete mode 100644 internal/provider/manual.go delete mode 100644 internal/provider/process.go delete mode 100644 internal/provider/provider.go delete mode 100644 internal/provider/provider_coverage_test.go delete mode 100644 internal/provider/provider_internal_test.go delete mode 100644 internal/provider/provider_test.go delete mode 100644 internal/provider/provider_v1_internal_test.go delete mode 100644 internal/provider/providers_v1_test.go delete mode 100644 internal/provider/sarif.go delete mode 100644 internal/repair/repair.go delete mode 100644 internal/repair/repair_coverage_test.go delete mode 100644 internal/repair/repair_internal_test.go delete mode 100644 internal/repair/repair_test.go delete mode 100644 internal/repair/repair_v1_internal_test.go create mode 100644 internal/repo/repo.go create mode 100644 internal/repo/repo_test.go delete mode 100644 internal/report/report.go delete mode 100644 internal/report/report_coverage_test.go delete mode 100644 internal/report/report_test.go delete mode 100644 internal/report/report_v1_test.go delete mode 100644 internal/security/fuzz_test.go delete mode 100644 internal/security/security.go delete mode 100644 internal/security/security_coverage_test.go delete mode 100644 internal/security/security_test.go delete mode 100644 internal/security/security_v1_internal_test.go delete mode 100644 internal/security/security_v1_test.go delete mode 100644 internal/verdict/benchmark_test.go delete mode 100644 internal/verdict/fuzz_test.go delete mode 100644 internal/verdict/mutation_internal_test.go delete mode 100644 internal/verdict/verdict.go delete mode 100644 internal/verdict/verdict_coverage_test.go delete mode 100644 internal/verdict/verdict_test.go delete mode 100644 internal/verdict/verdict_v1_test.go delete mode 100644 internal/verify/verify.go delete mode 100644 internal/verify/verify_coverage_test.go delete mode 100644 internal/verify/verify_internal_test.go delete mode 100644 internal/verify/verify_test.go delete mode 100644 internal/verify/verify_v1_internal_test.go delete mode 100644 internal/version/version_internal_test.go delete mode 100644 pkg/schema/evidence.schema.json delete mode 100644 pkg/schema/ir.schema.json delete mode 100644 pkg/schema/plan.schema.json delete mode 100644 pkg/schema/repair.schema.json delete mode 100644 pkg/schema/report.schema.json delete mode 100644 pkg/schema/requirement.schema.json delete mode 100644 pkg/schema/schema.go delete mode 100644 pkg/schema/schema_internal_test.go delete mode 100644 pkg/schema/schema_test.go delete mode 100644 pkg/schema/verdict.schema.json delete mode 100755 scripts/check-coverage.sh delete mode 100755 scripts/check_examples.sh delete mode 100755 scripts/check_fuzz.sh delete mode 100755 scripts/check_mutation.sh delete mode 100755 scripts/check_schemas.sh delete mode 100755 scripts/cross_compile.sh delete mode 100755 scripts/package_release.sh delete mode 100755 scripts/record_performance.sh delete mode 100755 scripts/validate_v1_release.sh delete mode 100644 tests/acceptance/v1_acceptance_test.go delete mode 100644 tests/performance/performance_test.go delete mode 100644 v1.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71b960d..ee057de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,63 +9,20 @@ permissions: contents: read jobs: - release-validation: - name: release-validation (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} + macos: + name: macos + runs-on: macos-26 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - fetch-depth: 0 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: go-version: "1.23.x" cache: true - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.13" - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: "22" - cache: npm - cache-dependency-path: examples/typescript/package-lock.json - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 - with: - distribution: temurin - java-version: "21" - - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - - name: Full v1 release validation - run: ./scripts/validate_v1_release.sh dist/release-evidence - - name: Build dogfood binary - run: go build -trimpath -o intentci ./cmd/intentci - - name: Dogfood strict compile - run: ./intentci compile --strict - - name: Dogfood verification - run: ./intentci verify --all --no-cache --format json --output intentci-report.json - - name: Upload release evidence - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: release-evidence-${{ matrix.os }} - path: | - dist/release-evidence/ - intentci-report.json - - build-matrix: - name: build-matrix - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: "1.23.x" - cache: true - - name: Cross-compile supported targets - run: ./scripts/cross_compile.sh dist/cross-compile - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: cross-compiled-binaries - path: dist/cross-compile/ + - name: Test with race detection + run: go test -race ./... + - name: Vet + run: go vet ./... + - name: Build + run: go build -trimpath ./cmd/intentci + - name: Dogfood all checks + run: go run ./cmd/intentci --all diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 519bf3d..cab2c88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,128 +3,82 @@ name: release on: push: tags: - - "v*" + - "v2.*" permissions: contents: write jobs: - macos-performance: - runs-on: macos-latest - permissions: - contents: read - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - fetch-depth: 0 - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: "1.23.x" - cache: true - - name: Record native macOS performance - run: ./scripts/record_performance.sh dist/release-evidence/performance-macos-arm64.txt - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: macos-performance - path: dist/release-evidence/performance-macos-arm64.txt - publish: - needs: macos-performance - runs-on: ubuntu-latest + runs-on: macos-26 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version: "1.25.x" + go-version: "1.23.x" cache: true - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: "3.13" - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: "22" - cache: npm - cache-dependency-path: examples/typescript/package-lock.json - - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 - with: - distribution: temurin - java-version: "21" - - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - - name: Install pinned mutation runner + - name: Validate run: | - go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0 - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - - name: Run complete release gates - env: - INTENTCI_RUN_MUTATION: "1" - RELEASE_TAG: ${{ github.ref_name }} + go test -race ./... + go vet ./... + go run ./cmd/intentci --all + - name: Build Apple Silicon archive run: | - INTENTCI_BUILD_VERSION="${RELEASE_TAG#v}" \ - ./scripts/validate_v1_release.sh dist/release-evidence - - name: Collect platform performance records - run: mv dist/release-evidence/performance.txt dist/release-evidence/performance-linux-amd64.txt - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: macos-performance - path: dist/release-evidence - - name: Build deterministic release archives - run: ./scripts/package_release.sh "${GITHUB_REF_NAME#v}" dist/release - - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: release-validation-evidence - path: dist/release-evidence/ + version="${GITHUB_REF_NAME#v}" + mkdir -p dist/stage dist/release + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \ + go build -trimpath \ + -ldflags="-s -w -X github.com/hypertrial/intentci/v2/internal/version.Version=$version" \ + -o dist/stage/intentci ./cmd/intentci + tar -czf "dist/release/intentci_${version}_darwin_arm64.tar.gz" \ + -C dist/stage intentci + cd dist/release + shasum -a 256 "intentci_${version}_darwin_arm64.tar.gz" > checksums.txt - name: Publish GitHub release uses: softprops/action-gh-release@c12583777ecdfd3be55c69cf75464299dc01057e # v3 with: files: | - dist/release/*.tar.gz + dist/release/intentci_*.tar.gz dist/release/checksums.txt - dist/release-evidence/acceptance-v1.json - dist/release-evidence/performance-*.txt - dist/release-evidence/mutation/*.json - body_path: docs/releases/${{ github.ref_name }}.md + body_path: docs/releases/v2.0.0.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - validate-assets: + validate: needs: publish - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - target_os: linux - target_arch: amd64 - - os: macos-latest - target_os: darwin - target_arch: arm64 - runs-on: ${{ matrix.os }} + runs-on: macos-26 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - name: Download published assets + - name: Download and validate release env: GH_TOKEN: ${{ github.token }} - run: gh release download "$GITHUB_REF_NAME" --dir downloaded - - name: Verify all checksums run: | + gh release download "$GITHUB_REF_NAME" --dir downloaded cd downloaded - if command -v sha256sum >/dev/null 2>&1; then - sha256sum -c checksums.txt - else - shasum -a 256 -c checksums.txt - fi - - name: Run native packaged binary - run: | - archive="downloaded/intentci_${GITHUB_REF_NAME#v}_${{ matrix.target_os }}_${{ matrix.target_arch }}.tar.gz" + shasum -a 256 -c checksums.txt + archive="intentci_${GITHUB_REF_NAME#v}_darwin_arm64.tar.gz" + test "$(tar -tzf "$archive")" = "intentci" mkdir unpacked tar -xzf "$archive" -C unpacked test "$(unpacked/intentci version)" = "${GITHUB_REF_NAME#v}" - - name: Packaged smoke workflow + - name: Packaged binary smoke test run: | + binary="$GITHUB_WORKSPACE/downloaded/unpacked/intentci" fixture="$(mktemp -d)" cd "$fixture" - "$GITHUB_WORKSPACE/unpacked/intentci" init - "$GITHUB_WORKSPACE/unpacked/intentci" compile --strict - "$GITHUB_WORKSPACE/unpacked/intentci" verify --all --no-git --no-cache + git init -q + git config user.email intentci@example.com + git config user.name IntentCI + printf 'module example\n\ngo 1.23\n' > go.mod + printf 'package example\n' > example.go + "$binary" init + git add . + git commit -qm initial + printf '\nconst Changed = true\n' >> example.go + mkdir nested + cd nested + "$binary" + cd .. + "$binary" --all diff --git a/.gitignore b/.gitignore index 69be238..f924b8d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ go.work.sum # env file .env +.DS_Store # Local build artifacts /intentci diff --git a/.intentci.yaml b/.intentci.yaml new file mode 100644 index 0000000..517252e --- /dev/null +++ b/.intentci.yaml @@ -0,0 +1,10 @@ +version: 2 + +checks: + - id: go-tests + intent: IntentCI changes must keep tests passing. + paths: + - "**/*.go" + - go.mod + - go.sum + run: go test ./... diff --git a/.intentci/.gitignore b/.intentci/.gitignore deleted file mode 100644 index 33cf9bf..0000000 --- a/.intentci/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -runs/ -cache/ -tmp/ -config.local.yaml diff --git a/.intentci/config.yaml b/.intentci/config.yaml deleted file mode 100644 index aa37d65..0000000 --- a/.intentci/config.yaml +++ /dev/null @@ -1,48 +0,0 @@ -version: 1 - -project: - name: intentci - -requirements: - paths: - - .intentci/requirements/**/*.md - -verification: - default_timeout: 15m - max_parallel: 4 - working_directory: . - -change_impact: - base_ref: origin/main - include_untracked: true - run_unmapped_requirements: false - -evidence: - directory: .intentci/runs - retain_stdout: true - retain_stderr: true - hash_algorithm: sha256 - redact: - environment: - - "*TOKEN*" - - "*SECRET*" - - "*PASSWORD*" - - "*KEY*" - -repair: - max_attempts: 3 - stop_on_repeated_diff: true - stop_on_repeated_failure: true - allow_requirement_changes: false - allow_test_changes: true - -ci: - fail_on: - - fail - - error - - unproven - - uncertain - - review_required - -telemetry: - enabled: false diff --git a/.intentci/requirements/BUILD-001.md b/.intentci/requirements/BUILD-001.md deleted file mode 100644 index aa66f7f..0000000 --- a/.intentci/requirements/BUILD-001.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -id: BUILD-001 -title: IntentCI packages test cleanly -status: active -priority: required -owners: - - intentci -depends_on: [] -applies_to: - paths: - - cmd/** - - internal/** - - pkg/** - - go.mod - - go.sum -tags: - - build ---- - -# Intent - -The IntentCI Go packages under cmd/, internal/, and pkg/ must pass unit and integration tests. - -# Rationale - -A broken test suite cannot gate product intent. - -# Constraints - -## Must - -- id: CON-001 - statement: Use the repository Go module test command. - -## Must Not - -- id: CON-002 - statement: Do not skip the coverage gate in CI. - -# Boundaries - -```yaml -allowed: - - cmd/** - - internal/** - - pkg/** - - go.mod - - go.sum - - scripts/** - - docs/** - - examples/** - - .github/** - - .intentci/** -forbidden: [] -``` - -# Obligations - -```yaml -- id: OBL-001 - statement: go test ./... passes - required: true - verify: - all: - - provider: command - id: go-test - run: "go test ./... && printf 'intentci-ok\n'" - inherit_environment: - - HOME - - GOCACHE - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -``` diff --git a/.intentci/requirements/PRIVACY-001.md b/.intentci/requirements/PRIVACY-001.md deleted file mode 100644 index 9ff0070..0000000 --- a/.intentci/requirements/PRIVACY-001.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -id: PRIVACY-001 -title: Telemetry remains disabled by default -status: active -priority: required -owners: - - intentci -depends_on: [] -applies_to: - paths: - - internal/config/** - - .intentci/config.yaml -tags: - - privacy ---- - -# Intent - -IntentCI must not enable telemetry by default. - -# Rationale - -Local-first operation requires explicit opt-in for any outbound product telemetry. - -# Obligations - -```yaml -- id: OBL-001 - statement: Default config has telemetry.enabled false - required: true - verify: - all: - - provider: command - id: telemetry-default - run: "go test ./internal/config -run TestDefaultAndValidate && printf 'intentci-ok\n'" - inherit_environment: - - HOME - - GOCACHE - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b50c0..4c72196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,81 +1,41 @@ # Changelog -## 1.1.1 — 2026-07-27 - -### Fixed - -- Probabilistic provider evidence that omits its observed confidence can no - longer inherit the obligation's minimum confidence threshold and pass. -- The release validator now executes native Go fuzz targets for every - property-based invariant required by v1 §34.5. -- Performance evidence now records peak resident memory for the no-op - `version` invocation as the v1 idle-memory proxy. +## 2.0.0 — 2026-07-27 -## 1.1.0 — 2026-07-27 - -First release validated against the complete normative v1 contract. +IntentCI v2 is an intentionally incompatible, MacBook-first rewrite. ### Added -- Typed requirement, obligation, provider, retry, timeout, dependency, - platform, evidence-class, and confidence metadata -- Deterministic canonical IR and verification plans with stable hashes -- Complete command, JUnit, SARIF, JSONPath-subset, boundary, git-diff, manual, - and external-provider v1 behavior -- Provenance-complete Git repository state, impact selection, scheduler, cache, - evidence, reporting, and manifest models -- Immutable multi-attempt repair runs with pinned contracts, boundary and - protected-path enforcement, repeated-work detection, and independent - re-verification -- Explicit configuration precedence and documented `INTENTCI_*` overrides -- `verify --head`, `--provider`, `--max-parallel`, `--fail-fast`, and - `--no-git`; named repair-agent discovery -- Tracked Go, Python, TypeScript, Rust, and Java examples and integration - fixtures -- Linux/macOS release validation, 20 executable acceptance criteria, - zero-survivor mutation checks, performance records, deterministic archives, - checksums, and packaged-binary smoke tests - -### Fixed +- One strict `.intentci.yaml` file containing intent, path globs, and commands +- Changed-file selection for staged, unstaged, renamed, deleted, and untracked files +- Sequential fail-fast execution through login `zsh` +- Stack-detecting `init`, `--all`, `version`, and compact help +- Apple Silicon macOS release and smoke validation -- False passes from stale generated reports, incomplete or informational - evidence, low-confidence probabilistic evidence, cancellation, and stale - cache keys -- Invalid status/priority acceptance, unsafe paths and symlinks, unmapped - change handling, verifier exit mapping, and repair attempt semantics -- Per-attempt repository state and binary diff persistence in repair evidence - bundles - -### Compatibility +### Removed -Valid v1.0.x requirements and configuration remain accepted. Invalid or -ambiguous inputs that v1.0.x silently accepted now fail with actionable -diagnostics. See -[docs/migration-v1.0-to-v1.1.md](docs/migration-v1.0-to-v1.1.md). +- Markdown requirements, compiler, IR, providers, evidence, reports, cache, + scheduler, repair agents, runtime schemas, and configuration overlays +- Linux and Intel Mac releases +- Every direct dependency except YAML and doublestar glob matching -## 1.0.0 — Breaking rewrite +See [the v1 migration guide](docs/migration-v1-to-v2.md). The complete v1 +history remains preserved by its immutable tags and GitHub releases. -IntentCI v1 replaces the v0.x Product Contract model. +## 1.1.1 — 2026-07-27 -### Added +Final v1 release. It corrected missing-confidence handling for probabilistic +evidence and completed the v1 release-validation suite. -- Markdown requirements with YAML front matter and obligation providers -- Canonical Intent IR (`intentci compile`) -- Providers: command, junit, sarif, boundary, git-diff, json, manual -- Evidence bundles under `.intentci/runs/` -- Bounded `intentci repair` with repair packets and protected-path checks -- `status`, `doctor`, `schema` commands -- Exit codes `0`–`10` per v1 specification -- Apache License 2.0 +## 1.1.0 — 2026-07-27 -### Removed +First release validated against the complete v1 contract. -- `.intentci/contract.yaml` Product Contracts -- Change Specs, waivers, hooks, `--attest`, `--trust`, `policy.semantic` -- Exit codes `10/11/12/20/21/30` (v0 meanings) +## 1.0.0 -See [docs/migration-v0-to-v1.md](docs/migration-v0-to-v1.md). +Breaking replacement of the v0 Product Contract model with Markdown +requirements, providers, evidence, and bounded repair. ## 0.4.0 -Semantic verification overlay on Product Contracts (final v0 line). +Final v0 release. diff --git a/README.md b/README.md index 9a4ef55..80de033 100644 --- a/README.md +++ b/README.md @@ -1,164 +1,108 @@ # IntentCI -**Intent compiler and evidence-based verification for agent-generated code.** - -IntentCI connects human intent to machine-verifiable evidence: - -```text -Requirement → Obligation → Verifier → Evidence → Verdict → Repair -``` - -It organizes existing tests and checks around Markdown requirements and reports obligation-level evidence before you push. +IntentCI runs the checks that matter for your current local changes. ```text -FAIL REQ-AUTH-001 - PASS OBL-001 - FAIL OBL-002 - PASS OBL-003 +changed files → matching checks → zsh commands → pass/fail ``` -## Status - -**v1.1.1** is the current release validated against the complete normative -[`v1.md`](v1.md), including the executable -[§38 acceptance matrix](docs/acceptance-v1.md). It supersedes v1.1.0 by -correcting missing-confidence handling for probabilistic evidence. Earlier -releases remain available as immutable historical tags. - -Breaking change from v0.x Product Contracts: [docs/migration-v0-to-v1.md](docs/migration-v0-to-v1.md). -Existing v1.0.x users: [v1.0.x → v1.1 migration](docs/migration-v1.0-to-v1.1.md). +Version 2 is intentionally small and incompatible with v1. The complete v1 +implementation remains available from the immutable +[`v1.1.1`](https://github.com/hypertrial/intentci/releases/tag/v1.1.1) +release. ## Install -### From release binaries +IntentCI v2 supports Apple Silicon Macs. +Source builds on other platforms are incidental and unsupported. -Download the binary from [GitHub Releases](https://github.com/hypertrial/intentci/releases), make it executable, and place it on your `PATH`. +Download `intentci_2.0.0_darwin_arm64.tar.gz` from +[GitHub Releases](https://github.com/hypertrial/intentci/releases), or install +from source with Go 1.23 or newer: ```bash -chmod +x intentci -sudo mv intentci /usr/local/bin/ -intentci version +go install github.com/hypertrial/intentci/v2/cmd/intentci@v2.0.0 ``` -### From source - -Requires Go 1.23+. - -```bash -go install github.com/hypertrial/intentci/cmd/intentci@v1.1.1 -``` +## Start -For development: +From any directory inside a Git repository: ```bash -git clone https://github.com/hypertrial/intentci.git -cd intentci -go install ./cmd/intentci +intentci init ``` -## Quickstart - -```bash -cd your-repo -intentci init -# edit .intentci/requirements/*.md -intentci compile --strict -intentci verify --changed -intentci explain REQ-001 --show-evidence +`init` detects a root-level Go, Node, Python, Rust, Maven, or Gradle project and +creates `.intentci.yaml`. Edit it whenever the generated command or paths are +not the right ones for your repository. + +```yaml +version: 2 + +checks: + - id: go-tests + intent: Go changes must keep tests passing. + paths: + - "**/*.go" + - go.mod + - go.sum + run: go test ./... ``` -Repair with an external agent command: +Then use one command during normal development: ```bash -intentci repair \ - --agent-command './scripts/run-agent.sh {packet}' \ - --max-attempts 3 +intentci ``` -## CLI +IntentCI finds staged, unstaged, deleted, renamed, and untracked non-ignored +files relative to `HEAD`. It runs matching checks in YAML order and stops at +the first failure. A change to `.intentci.yaml` runs every check. -| Command | Purpose | -| --- | --- | -| `intentci init` | Create `.intentci/config.yaml` + example requirement | -| `intentci compile` | Compile Markdown → canonical Intent IR | -| `intentci verify` | Select, execute providers, emit verdicts | -| `intentci explain ` | Explain a requirement from the latest run | -| `intentci repair` | Bounded agent repair loop | -| `intentci status` | Repository status from latest run | -| `intentci doctor` | Local dependency / config checks | -| `intentci schema ` | Print JSON schemas | -| `intentci version` | Print version | +Run everything explicitly with: -### Verify flags - -```text ---all Verify all active requirements ---changed Verify requirements affected by the Git diff (default; empty diff verifies nothing) ---requirement Single requirement ---obligation Single obligation ---provider Single verifier id ---base Comparison base ---head Comparison head ---max-parallel Override bounded concurrency ---fail-fast Stop scheduling new work after a non-pass ---no-git Allow all/explicit verification without Git provenance ---format text|json|junit Output format ---output Write report to a file ---no-cache Disable successful-provider cache +```bash +intentci --all ``` -Invalid formats and empty selectors use exit `8`. +## Configuration -### Repair agents +Every field is required: -`--agent NAME` resolves `intentci-agent-NAME` on `PATH`. It is mutually -exclusive with `--agent-command`. `{packet}`, `{repository}`, and `{attempt}` -placeholders are expanded for command adapters. +- `version` must be `2`. +- `checks` must contain at least one check. +- `id` must be unique and match `[a-z0-9][a-z0-9-]*`. +- `intent` explains why the check exists. +- `paths` contains repository-relative + [doublestar](https://github.com/bmatcuk/doublestar) globs. Use `**` for every + changed file. +- `run` is a command passed to `/bin/zsh -lc`; YAML multiline strings work. -`repair.max_attempts` is the total number of immutable verification attempts, -including the initial failed state. No agent runs after the last permitted -verification. IntentCI v1 executes agents on the host and is not a sandbox. +Unknown fields, absolute or traversing paths, backslashes, malformed globs, and +empty values are errors. There are no includes, overlays, retries, providers, +reports, caches, or environment overrides. -## Exit codes +## Commands -| Code | Meaning | -| --- | --- | -| `0` | Passed | -| `1` | Requirement failed | -| `2` | Unproven | -| `3` | Uncertain | -| `4` | Review required | -| `5` | Compile failed | -| `6` | Verifier execution error | -| `7` | Internal error | -| `8` | Invalid CLI usage | -| `9` | Repair attempts exhausted | -| `10` | Security / boundary violation | - -Agents should consume JSON (`--format json`) rather than parsing terminal text. - -## Evidence - -Each run under `.intentci/runs//` contains canonical IR, the selected -plan, initial and per-attempt repository state/diffs, evidence, verdicts, logs, -artifacts, terminal/JSON/JUnit reports, a manifest, and a final verdict that -records the manifest hash. Attempts and finalized runs are immutable. - -See the [configuration reference](docs/configuration.md), -[provider protocol](docs/provider-protocol-v1.md), and -[security model](docs/SECURITY.md). The -[normative conformance index](docs/v1-conformance.md) maps every v1 section to -its executable release control. +```text +intentci Run checks matching working-tree changes +intentci --all Run every check +intentci init Create .intentci.yaml without overwriting +intentci version Print the version +intentci --help Print usage +``` -## Privacy +Exit codes are `0` for success or no matching work, `1` for a failed check, `2` +for usage/configuration/Git/launch errors, `130` for `SIGINT`, and `143` for +`SIGTERM`. -Telemetry is off by default (`telemetry.enabled: false`). IntentCI executes -repository-defined providers and repair agents locally with a minimal -environment plus explicit allowlists. HTTP is only used by tools you configure. +## Trust -## Platform support +IntentCI is not a sandbox. Commands run from the repository root with your full +environment and user permissions. Only run checks from repositories you trust. +No results are persisted and IntentCI does not access the network itself. -macOS amd64/arm64 and Linux amd64. Windows via WSL. +See [migration guidance](docs/migration-v1-to-v2.md) when moving from v1. ## License diff --git a/cmd/intentci/main.go b/cmd/intentci/main.go index 5a407bc..3c54c4b 100644 --- a/cmd/intentci/main.go +++ b/cmd/intentci/main.go @@ -3,11 +3,11 @@ package main import ( "os" - "github.com/hypertrial/intentci/internal/cli" + "github.com/hypertrial/intentci/v2/internal/app" ) var exitFunc = os.Exit func main() { - exitFunc(cli.RunMain(os.Args[1:], os.Stdout, os.Stderr)) + exitFunc(app.Main(os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 567997d..90fcd8a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,30 +1,19 @@ # Contributing -## Development +IntentCI v2 is deliberately narrow. Add behavior only when it is needed by the +local Apple Silicon Mac workflow. + +Before opening a pull request: ```bash -go test ./... -./scripts/check-coverage.sh go test -race ./... -go build -o intentci ./cmd/intentci -./intentci compile --strict -./intentci verify --all --no-cache -./scripts/check_examples.sh -./scripts/validate_v1_release.sh +go vet ./... +go build -trimpath ./cmd/intentci +go run ./cmd/intentci --all ``` -## Branching - -Feature work lands through pull requests to protected `main`. Public tags are -immutable and reserved for verified releases. - -## Style - -- Prefer interfaces over package-level `var` hooks for testability. -- Keep provider contracts in `internal/provider`. -- Add behavior-focused tests for every change and keep the 100% statement gate. -- Update the executable §38 suite when an acceptance contract changes. - -## License +Prefer the standard library, preserve strict input validation, and include one +focused test for non-trivial behavior. Feature work lands through pull requests +to protected `main`; published tags are immutable. Contributions are accepted under the Apache License 2.0. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c8f5440..8bdcdd1 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,54 +1,17 @@ # Security -## Host execution trust boundary +IntentCI v2 is a local command runner, not a sandbox. -IntentCI v1 is not a sandbox. Command providers, external providers, and repair -agents execute on the host as the current user. Treat an untrusted repository -like an untrusted build script. Run it in an isolated CI worker or VM when that -trust is not appropriate. Repair prints a warning before invoking an agent. +Checks execute as `/bin/zsh -lc` from the repository root with the current +user's complete environment and permissions. A repository can therefore read, +modify, or transmit anything the user can. Inspect `.intentci.yaml` before +running IntentCI in an untrusted repository; use a disposable VM when trust is +uncertain. -## Paths and artifacts +IntentCI validates its configuration and path globs, writes only +`.intentci.yaml` during `init`, persists no command output, has no telemetry, +and performs no network access itself. Commands may still write files, reveal +environment values, or use the network. -Provider working directories, reports, outputs, artifacts, protected paths, and -evidence locations must be repository-relative. IntentCI rejects absolute -paths, traversal, and symlink escape. Security or boundary stops use exit `10`. - -The default repair-protected paths include `.intentci/**` and -`.github/workflows/**`. Contract/config hashes are pinned before the first -attempt, so pre-dirty protected content and agent changes are checked against -the original state. - -## Environment and secrets - -Providers receive a minimal environment plus explicit `inherit_environment` -and `environment` entries. IntentCI injects only documented run metadata. -Do not embed credentials in requirements, provider URLs, commands, or repair -packets. - -`evidence.redact.environment` matches environment variable names. Their current -values and conventional `NAME=value` renderings are redacted before logs, -reports, packets, and JSON evidence reach disk. Redaction is defense in depth, -not permission to print secrets: transformed, encoded, split, or previously -persisted credentials may not be recognizable. - -## Evidence and interruption - -Run files use same-directory temporary writes and rename. Attempts and finalized -runs are immutable. `manifest.json` hashes all immutable artifacts except -itself and `final-verdict.json`; the final verdict records the manifest hash. - -`SIGINT` and `SIGTERM` cancel provider commands, retain completed evidence and -partial logs, mark incomplete work as error, and cannot produce a passing final -verdict. - -## Telemetry and network - -Telemetry defaults to disabled and IntentCI does not phone home. Network access -occurs only through repository-defined commands, external providers, agents, or -the operator's package/release tooling. - -## Reporting vulnerabilities - -Do not open a public issue for a suspected vulnerability. Use GitHub's private -security-advisory reporting for `hypertrial/intentci` and include affected -versions, reproduction steps, impact, and any suggested mitigation. +Report suspected vulnerabilities through GitHub's private security-advisory +flow for `hypertrial/intentci`, not a public issue. diff --git a/docs/acceptance-v0.1.md b/docs/acceptance-v0.1.md deleted file mode 100644 index 4abfcff..0000000 --- a/docs/acceptance-v0.1.md +++ /dev/null @@ -1,29 +0,0 @@ -# v0.1.0 Acceptance Checklist - -Complete before tagging `v0.1.0`. - -## Automated - -- [ ] `go test ./...` passes locally on macOS or Linux -- [ ] `go vet ./...` passes -- [ ] CI green on `ubuntu-latest` and `macos-latest` -- [ ] Cross-compile artifacts build for `linux/amd64`, `darwin/amd64`, `darwin/arm64` -- [ ] Fixture integration test covers pass and fail paths (`fixtures/go-service`) - -## Manual - -- [ ] `intentci init` in a fresh temp Git repo creates `.intentci/contract.yaml` -- [ ] Promoting a draft requirement to `approved` and running `intentci validate` succeeds -- [ ] `intentci verify --trust --all --format json` on this repository exits `0` on a clean tree (or correctly reports affected failures) -- [ ] Missing `policy.default_base` / `--base` ref exits `21` with a clear error (no silent fallback) -- [ ] JSON output includes `schema_version`, `requirements`, `summary`, and stable status strings -- [ ] First run without `--trust` prompts before executing checks -- [ ] README quickstart is accurate -- [ ] [docs/v0.1.md](v0.1.md) and [docs/roadmap.md](roadmap.md) match shipped CLI surface - -## Release - -- [ ] Version string via `intentci version` reflects `0.1.0` for release builds -- [ ] Tag `v0.1.0` created -- [ ] GitHub Release published with binaries and checksums -- [ ] No IntentCI-initiated network requests in default paths (code review) diff --git a/docs/acceptance-v0.2.md b/docs/acceptance-v0.2.md deleted file mode 100644 index 99a986c..0000000 --- a/docs/acceptance-v0.2.md +++ /dev/null @@ -1,13 +0,0 @@ -# v0.2.0 Acceptance Checklist - -- [ ] `./scripts/check-coverage.sh` reports 100.0% -- [ ] CI enforces coverage on Linux and macOS -- [ ] `intentci change create FOO-1` creates `.intentci/changes/FOO-1.yaml` -- [ ] `intentci verify --change FOO-1` surfaces AC statuses -- [ ] `affected_requirements` force selection without path hits -- [ ] Approved Change Spec edits (and demotions) appear in `change_findings` vs merge-base -- [ ] Change Spec filename id must match YAML `id` -- [ ] Cache hit/invalidate/corrupt/no-inputs behaviors -- [ ] `--no-cache` forces re-execution -- [ ] `intentci explain BUILD-001` works offline -- [ ] GitHub Release `v0.2.0` published diff --git a/docs/acceptance-v0.3.md b/docs/acceptance-v0.3.md deleted file mode 100644 index 1e8b92b..0000000 --- a/docs/acceptance-v0.3.md +++ /dev/null @@ -1,18 +0,0 @@ -# v0.3.0 Acceptance Checklist - -- [ ] `./scripts/check-coverage.sh` reports 100.0% -- [ ] CI enforces coverage on Linux and macOS -- [ ] `intentci hook install` / `uninstall` compose and strip marked sections only -- [ ] Unmanaged pre-push without markers is refused (no silent overwrite) -- [ ] `intentci verify --attest` writes attestation on PASS; skips write on non-PASS -- [ ] Contract weakenings vs merge-base appear in `contract_changes` -- [ ] Effective base policy still verifies removed/weakened base requirements -- [ ] Without `type: contract` Change Spec, weakenings force overall `unverified` -- [ ] With approved `type: contract` Change Spec, weakenings are reported but do not force unverified -- [ ] Approved Change Spec waiver yields requirement status `waived` and does not fail the run -- [ ] Draft Change Spec waivers do not skip blocking failures -- [ ] Expired / incomplete waivers fail `validate` / `verify` (exit 20) -- [ ] Softening `unknown_blocks` / `unverified_blocks` or removing JUnit `results` appears in `contract_changes` and is restored by effective policy -- [ ] `--attest` skips writing when any check record is fail/unknown even if overall PASS via waiver -- [ ] JUnit `results.format: junit` maps suite failures to check FAIL and parse errors to UNKNOWN -- [ ] GitHub Release `v0.3.0` published diff --git a/docs/acceptance-v0.4.md b/docs/acceptance-v0.4.md deleted file mode 100644 index 99ef11c..0000000 --- a/docs/acceptance-v0.4.md +++ /dev/null @@ -1,18 +0,0 @@ -# v0.4.0 Acceptance Checklist - -- [ ] `./scripts/check-coverage.sh` reports 100.0% -- [ ] CI enforces coverage on Linux and macOS -- [ ] Default / `policy.semantic.enabled: false` path makes no IntentCI-initiated network requests -- [ ] `enabled: true` without provider fails `validate` / does not silently call a provider -- [ ] Local provider returns structured findings merged into requirement results -- [ ] HTTP provider only contacts the configured URL; token from `INTENTCI_SEMANTIC_TOKEN` only -- [ ] `intentci verify --show-semantic-input` prints request JSON without `--trust`, without running checks, and without invoking a provider -- [ ] Deterministic check FAIL is never overridden by a positive semantic assessment -- [ ] Advisory mode never converts deterministic PASS → FAIL -- [ ] Blocking FAIL only when confidence, evidence, approved, and `semantic: required` all hold -- [ ] `verification.semantic: required` with unavailable provider yields requirement `unknown` -- [ ] Goals and non-goals from a Change Spec appear in semantic input -- [ ] `intentci explain` surfaces semantic findings from the last local result -- [ ] Contract weakenings include disabling required semantic / removing provider / softening enforcement -- [ ] Version string reflects `0.4.0-dev` (dev) / `0.4.0` (release builds) -- [ ] GitHub Release `v0.4.0` published diff --git a/docs/acceptance-v1.md b/docs/acceptance-v1.md deleted file mode 100644 index 40ae9bf..0000000 --- a/docs/acceptance-v1.md +++ /dev/null @@ -1,70 +0,0 @@ -# v1 acceptance matrix - -IntentCI v1 conformance is executable. The canonical gate is: - -```bash -./scripts/validate_v1_release.sh -``` - -It runs vet, race detection, the 100% statement-coverage gate, runtime schema -checks, all five language examples, cross-compilation, performance recording, -and the named §38 acceptance suite. The suite emits -`dist/release-evidence/acceptance-v1.json`. - -Mutation checks are release-only because they are intentionally expensive: - -```bash -INTENTCI_RUN_MUTATION=1 ./scripts/validate_v1_release.sh -``` - -## v1.md §38 - -Every checked item below links to the executable suite that generated the -machine-readable matrix. A release is blocked if any subtest or surrounding -release gate fails. - -- [x] AC-01 — initialize an existing repository -- [x] AC-02 — author requirements in Markdown -- [x] AC-03 — compile byte-stable canonical JSON -- [x] AC-04 — reject invalid graphs with actionable diagnostics -- [x] AC-05 — verify obligations with existing test commands -- [x] AC-06 — map JUnit and SARIF reports -- [x] AC-07 — detect boundary violations -- [x] AC-08 — select requirements from changed paths -- [x] AC-09 — bind evidence to repository and contract hashes -- [x] AC-10 — assign every required obligation a verdict -- [x] AC-11 — prevent missing evidence from passing -- [x] AC-12 — produce a structured repair packet -- [x] AC-13 — invoke an external agent within a bounded loop -- [x] AC-14 — reject protected contract modification -- [x] AC-15 — stop repeated ineffective attempts -- [x] AC-16 — generate terminal, JSON, and JUnit reports -- [x] AC-17 — run in GitHub Actions without a service -- [x] AC-18 — build for Linux and macOS -- [x] AC-19 — keep telemetry disabled by default -- [x] AC-20 — cover both required end-to-end workflows and their manifests - -Executable source: -[`tests/acceptance/v1_acceptance_test.go`](../tests/acceptance/v1_acceptance_test.go). - -## v1.1.0 release evidence - -The protected `main` commit `04f87d4` passed -`release-validation (ubuntu-latest)`, `release-validation (macos-latest)`, and -`build-matrix`: -[GitHub Actions run 30273342392](https://github.com/hypertrial/intentci/actions/runs/30273342392). -The tag workflow publishes its machine-readable acceptance matrix, Linux and -macOS performance records, and mutation reports alongside the v1.1.0 release. - -## Release blockers - -A v1 release cannot proceed with: - -- an unchecked AC-01–AC-20 matrix entry; -- statement coverage below 100%; -- a race, vet, schema, example, or cross-platform failure; -- an unexplained live covered mutant in verdict, compiler, boundary, or repair - contract-immutability code; -- a P0/P1 final review finding; -- a missing Linux or macOS required check; -- a dirty release worktree or missing release artifact. diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index c51285c..0000000 --- a/docs/configuration.md +++ /dev/null @@ -1,60 +0,0 @@ -# Configuration - -IntentCI resolves configuration in this order, from highest to lowest: - -1. command-line flags; -2. explicit `INTENTCI_*` environment variables; -3. `.intentci/config.local.yaml`; -4. `.intentci/config.yaml`; -5. built-in defaults. - -Unknown YAML keys and malformed environment values are errors. -`config.local.yaml` is gitignored by `intentci init`. - -## Environment overrides - -Strings are used verbatim. Booleans and integers use Go syntax. The timeout -uses Go duration syntax such as `30s` or `5m`. Lists must be JSON arrays, not -comma-separated strings. - -| YAML leaf | Environment variable | Type | -| --- | --- | --- | -| `version` | `INTENTCI_VERSION` | integer | -| `project.name` | `INTENTCI_PROJECT_NAME` | string | -| `requirements.paths` | `INTENTCI_REQUIREMENTS_PATHS` | JSON string array | -| `verification.default_timeout` | `INTENTCI_VERIFICATION_DEFAULT_TIMEOUT` | Go duration | -| `verification.max_parallel` | `INTENTCI_VERIFICATION_MAX_PARALLEL` | integer | -| `verification.fail_fast` | `INTENTCI_VERIFICATION_FAIL_FAST` | boolean | -| `verification.working_directory` | `INTENTCI_VERIFICATION_WORKING_DIRECTORY` | string | -| `verification.require_clean_worktree` | `INTENTCI_VERIFICATION_REQUIRE_CLEAN_WORKTREE` | boolean | -| `change_impact.base_ref` | `INTENTCI_CHANGE_IMPACT_BASE_REF` | string | -| `change_impact.include_untracked` | `INTENTCI_CHANGE_IMPACT_INCLUDE_UNTRACKED` | boolean | -| `change_impact.run_unmapped_requirements` | `INTENTCI_CHANGE_IMPACT_RUN_UNMAPPED_REQUIREMENTS` | boolean | -| `change_impact.fail_on_unmapped` | `INTENTCI_CHANGE_IMPACT_FAIL_ON_UNMAPPED` | boolean | -| `change_impact.global_paths` | `INTENTCI_CHANGE_IMPACT_GLOBAL_PATHS` | JSON string array | -| `evidence.directory` | `INTENTCI_EVIDENCE_DIRECTORY` | string | -| `evidence.retain_stdout` | `INTENTCI_EVIDENCE_RETAIN_STDOUT` | boolean | -| `evidence.retain_stderr` | `INTENTCI_EVIDENCE_RETAIN_STDERR` | boolean | -| `evidence.hash_algorithm` | `INTENTCI_EVIDENCE_HASH_ALGORITHM` | string | -| `evidence.redact.environment` | `INTENTCI_EVIDENCE_REDACT_ENVIRONMENT` | JSON string array | -| `repair.max_attempts` | `INTENTCI_REPAIR_MAX_ATTEMPTS` | integer | -| `repair.stop_on_repeated_diff` | `INTENTCI_REPAIR_STOP_ON_REPEATED_DIFF` | boolean | -| `repair.stop_on_repeated_failure` | `INTENTCI_REPAIR_STOP_ON_REPEATED_FAILURE` | boolean | -| `repair.allow_requirement_changes` | `INTENTCI_REPAIR_ALLOW_REQUIREMENT_CHANGES` | boolean | -| `repair.allow_test_changes` | `INTENTCI_REPAIR_ALLOW_TEST_CHANGES` | boolean | -| `repair.protected_paths` | `INTENTCI_REPAIR_PROTECTED_PATHS` | JSON string array | -| `ci.fail_on` | `INTENTCI_CI_FAIL_ON` | JSON string array | -| `telemetry.enabled` | `INTENTCI_TELEMETRY_ENABLED` | boolean | - -Example: - -```bash -export INTENTCI_VERIFICATION_MAX_PARALLEL=8 -export INTENTCI_CHANGE_IMPACT_GLOBAL_PATHS='["go.mod","go.sum",".github/**"]' -intentci verify --changed --fail-fast -``` - -Provider-level environment is separate. Providers receive a small baseline -environment, the `inherit_environment` allowlist, explicit `environment` -values, and the documented `INTENTCI_*` run variables. This prevents accidental -whole-environment capture. diff --git a/docs/migration-v0-to-v1.md b/docs/migration-v0-to-v1.md deleted file mode 100644 index ba9771a..0000000 --- a/docs/migration-v0-to-v1.md +++ /dev/null @@ -1,80 +0,0 @@ -# Migrating from IntentCI v0.x to v1.0.0 - -**Breaking release.** v1.0.0 replaces the Product Contract model. There is no dual-read of `.intentci/contract.yaml`. - -## Decision - -| Line | License | Model | -| --- | --- | --- | -| v0.4.x (last) | MIT | Product Contract YAML + Change Specs | -| v1.0.0+ | Apache-2.0 | Markdown requirements + obligations + providers | - -v0.4 binaries remain the last Product Contract line. Upgrade requires rewriting repository configuration. - -## Artifact mapping - -| v0.x | v1.0 | -| --- | --- | -| `.intentci/contract.yaml` | `.intentci/config.yaml` + `.intentci/requirements/**/*.md` | -| `requirements[].status: approved` | front matter `status: active` | -| `severity: blocking` | `priority: required` | -| `severity: advisory` | `priority: recommended` or `informational` | -| `verification.checks: [id]` | obligation `verify` expressions with `provider: command` | -| top-level `checks:` | inline provider configs on obligations | -| `.intentci/changes/*.yaml` Change Specs | temporary/change-scoped requirements as Markdown (or omit) | -| `policy.semantic` | deferred; use provider evidence classes / custom providers post-v1 | -| `intentci validate` | `intentci compile` | -| `intentci check` | `intentci verify --changed` (fast path via profiles/timeouts in config) | -| `intentci hook` / `--attest` | removed (use CI + evidence bundles) | -| `.intentci/tmp/last-result.json` | `.intentci/runs//` evidence bundles | - -## Status / verdict vocabulary - -| v0.x | v1.0 | -| --- | --- | -| `pass` | `pass` | -| `fail` | `fail` | -| `unverified` | `unproven` | -| `unknown` | `uncertain` or `error` (context-dependent) | -| `waived` | not first-class in v1 (use disabled requirement or manual review) | -| `not_affected` | skipped / not selected by impact | -| — | `review_required` (new) | -| — | `skipped` (obligation-level) | - -## Exit codes - -| Meaning | v0.x | v1.0 | -| --- | --- | --- | -| Pass | `0` | `0` | -| Failed requirement | `10` | `1` | -| Unproven / unverified | `11` | `2` | -| Uncertain / unknown | `12` | `3` | -| Review required | — | `4` | -| Invalid config / compile | `20` | `5` | -| Verifier execution error | — | `6` | -| Missing prerequisite | `21` | `6` or `8` (context) | -| Internal error | `30` | `7` | -| Invalid CLI usage | `1` (generic) | `8` | -| Repair exhausted | — | `9` | -| Security / boundary | — | `10` | - -Update CI scripts that branch on exit codes before upgrading. - -## Suggested migration steps - -1. Install IntentCI `v1.0.0`. -2. Back up `.intentci/`. -3. Run `intentci init --force` in a clean branch (or hand-author `config.yaml`). -4. For each approved blocking requirement, create a Markdown file under `.intentci/requirements/` with Intent, obligations, and `provider: command` mappings to existing tests. -5. Convert path includes to `applies_to.paths` and boundary rules to `provider: boundary` obligations where needed. -6. Delete `contract.yaml`, `changes/`, and v0 hook sections. -7. Run `intentci compile --strict` then `intentci verify --all`. -8. Point CI at `intentci verify --changed` (or `--all`) and the new exit-code table. - -## Removed features - -- Change Specs and waivers -- Managed git pre-push hooks -- `--attest` attestations -- `--trust` trusted-repos file (v1 treats local command execution as an explicit operator choice; protect via CI and repair immutability) -- `policy.semantic` local/HTTP overlay diff --git a/docs/migration-v1-to-v2.md b/docs/migration-v1-to-v2.md new file mode 100644 index 0000000..37cf937 --- /dev/null +++ b/docs/migration-v1-to-v2.md @@ -0,0 +1,49 @@ +# Migrating from IntentCI v1 to v2 + +Version 2 is intentionally incompatible with v1. It replaces requirements, +obligations, providers, evidence, reports, and repair with path-aware local +commands. + +The immutable +[`v1.1.1`](https://github.com/hypertrial/intentci/releases/tag/v1.1.1) +release remains available for repositories that need the v1 contract: + +```bash +go install github.com/hypertrial/intentci/cmd/intentci@v1.1.1 +``` + +## Convert a command obligation + +A v1 command obligation such as: + +```yaml +applies_to: + paths: ["**/*.go"] + +verify: + provider: command + run: go test ./... +``` + +becomes: + +```yaml +version: 2 + +checks: + - id: go-tests + intent: Go changes must keep tests passing. + paths: + - "**/*.go" + - go.mod + - go.sum + run: go test ./... +``` + +Create `.intentci.yaml`, verify it with `intentci --all`, then remove the old +`.intentci/` directory. `intentci init` deliberately stops when it finds a v1 +configuration so migration cannot happen silently. + +There is no automatic conversion for JUnit, SARIF, JSON, manual, boundary, +git-diff, or external providers; dependency graphs; evidence history; reports; +caching; or repair agents. Keep v1.1.1 if those capabilities remain necessary. diff --git a/docs/migration-v1.0-to-v1.1.md b/docs/migration-v1.0-to-v1.1.md deleted file mode 100644 index b5ce064..0000000 --- a/docs/migration-v1.0-to-v1.1.md +++ /dev/null @@ -1,58 +0,0 @@ -# Migrating from v1.0.x to v1.1.0 - -v1.1.0 keeps schema/protocol major version `1` and accepts valid v1.0.x -requirements and configuration. It is stricter about malformed input and adds -provenance fields to outputs. - -The published v1.0.0 and v1.0.1 tags are immutable historical releases. -v1.1.0, released 2026-07-27, supersedes them as the first release validated -against every normative v1 requirement. - -## Compatible additions - -- requirement and obligation dependency, timeout, retry, platform, evidence - class, and confidence metadata; -- typed provider working directory, environment, inputs, outputs, exclusivity, - retry, and artifact fields; -- repository base/head, dirty fingerprint, diff hash, rename/delete/binary, - line-count, mode, and untracked metadata; -- attempt, provider-version, plan-hash, source-evidence-hash, artifact, and - manifest provenance; -- verification-plan and stable-report schemas; -- CLI selectors and execution controls documented in the README. - -Consumers must ignore unknown additive fields within schema major version 1. - -## Inputs that now fail - -v1.1 correctly rejects inputs that v1.0.x might have accepted or silently -misinterpreted: - -- unknown YAML keys; -- invalid status, priority, verdict, evidence-class, or schema versions; -- invalid or absolute globs and unsafe paths; -- duplicate IDs, missing dependencies, and dependency cycles; -- empty or unsupported logical verifier expressions; -- unsupported JSON expressions and provider fields; -- empty CLI selectors and invalid formats. - -Compile failures use exit `5`; invalid CLI usage uses exit `8`. - -## Operational changes - -- successful evidence caching is provenance-complete and only applies to - deterministic passing evidence; -- generated JUnit/SARIF reports must be fresh; -- repair `max_attempts` counts the initial failed verification; -- repair pins the compiled contract for the entire run; -- evidence attempts are immutable and the final verdict references the - manifest hash; -- repair still executes on the host and prints a prominent warning. - -Recommended upgrade check: - -```bash -intentci compile --strict -intentci verify --all --no-cache --format json -./scripts/validate_v1_release.sh # when developing IntentCI itself -``` diff --git a/docs/performance-v1.md b/docs/performance-v1.md deleted file mode 100644 index 13bbf51..0000000 --- a/docs/performance-v1.md +++ /dev/null @@ -1,25 +0,0 @@ -# v1 performance validation - -Performance is recorded rather than gated by noisy hosted-runner wall time. - -```bash -./scripts/record_performance.sh -``` - -The record contains platform, Go version, commit, packaged binary size, peak -resident memory for the no-op `version` command, and five-run benchmark samples -for: - -- CLI startup; -- compilation of 100 and 1,000 requirements; -- change-impact analysis over 10,000 files; -- aggregation of 10,000 obligation/test-case verdicts; -- bounded scheduler overhead. - -Peak resident memory for `intentci version` is the v1 idle-memory proxy because -IntentCI is a terminating CLI rather than a resident service. - -The targets remain those in [v1.md §28](../v1.md). Absolute Linux and macOS -records are uploaded as release evidence. Regressions are reviewed against the -same platform's previous record; hosted wall time is not an absolute merge -gate. diff --git a/docs/provider-protocol-v1.md b/docs/provider-protocol-v1.md deleted file mode 100644 index 207cbf1..0000000 --- a/docs/provider-protocol-v1.md +++ /dev/null @@ -1,92 +0,0 @@ -# Provider protocol v1 - -Built-in and external providers produce evidence; they never assign the final -requirement verdict. - -## Provider fields - -Provider specifications support: - -- `provider`, `id`, `working_directory`; -- `inherit_environment` and explicit `environment`; -- `timeout`, `retry.attempts`, and `retry.backoff`; -- `depends_on`, `inputs`, `outputs`, and `exclusive`; -- `artifacts` for collected repository-relative output files; -- `evidence_class`; -- provider-specific `run`, `report`, `result`, `assert`, `match`, `allow`, - `allowed`, `forbidden`, `paths`, `expect`, `prompt`, and `configuration`. - -All paths are repository-relative and are checked for traversal and symlink -escape. Output conflicts and dependencies are part of the bounded execution -DAG. - -## External provider discovery - -The provider name `foo` resolves to `intentci-provider-foo` on `PATH`. -IntentCI writes one JSON request to stdin, reads one JSON response from stdout, -and treats stderr as diagnostics. A nonzero process exit, timeout, malformed -JSON, missing version, or incompatible protocol major is an error. - -Request shape: - -```json -{ - "protocol_version": "1.0", - "run_id": "01...", - "attempt_id": "attempt-001", - "requirement_id": "REQ-001", - "obligation_id": "OBL-001", - "repository": { - "root": "/absolute/repository/path", - "commit": "full-head-sha", - "base_commit": "full-base-sha", - "diff_hash": "sha256", - "changed_files": ["src/example.go"] - }, - "verifier": {}, - "configuration": {}, - "timeout_ms": 30000 -} -``` - -Response shape: - -```json -{ - "protocol_version": "1.0", - "provider": "foo", - "provider_version": "2.3.1", - "status": "completed", - "evidence": [ - { - "id": "check", - "class": "deterministic", - "summary": "check passed", - "passed": true - } - ], - "diagnostics": [], - "extra": {} -} -``` - -Unknown response fields are ignored for forward-compatible minor additions. -The `status` value is `completed`, `error`, or `skipped`. - -## JSON provider subset - -The v1 JSON provider accepts a deliberately small JSONPath-compatible subset: - -- root: `$`; -- member access: `$.metrics.coverage`; -- array index access: `$.results[0].level`. - -Operations are `exists`, `equals`, `not_equals`, `gt`, `gte`, `lt`, and `lte`. -Unsupported syntax is a compile error. - -## Generated reports - -When `run` is present on JUnit or SARIF providers, IntentCI removes an existing -report before execution. A generator must create a fresh report for that -invocation. A nonzero generator with a passing or missing report is `error`; a -fresh report containing violations is `fail`. diff --git a/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md deleted file mode 100644 index 384dd14..0000000 --- a/docs/releases/v1.1.0.md +++ /dev/null @@ -1,30 +0,0 @@ -# IntentCI v1.1.0 - -IntentCI v1.1.0 is the first release validated against the complete normative -v1 contract. It preserves valid v1.0.x input while correctly rejecting malformed -or ambiguous contracts that earlier releases could silently accept. - -Highlights: - -- typed, deterministic compilation and verification plans; -- complete built-in and external-provider v1 behavior; -- Git-aware impact selection, bounded DAG execution, and provenance-complete - successful-evidence caching; -- immutable evidence bundles with per-attempt state, reports, logs, artifacts, - manifests, and interruption safety; -- a reconstructed bounded repair loop with pinned contracts, protected paths, - patch/failure fingerprints, redacted agent logs, and independent verification; -- documented configuration precedence, provider protocol, schemas, migration, - and security model; -- tracked Go, Python, TypeScript, Rust, and Java examples; -- green Linux and macOS release gates, 100% statement coverage, 20/20 product - acceptance criteria, and 303/303 killed covered mutants in release-critical - code. - -The release contains reproducible Linux amd64 and macOS amd64/arm64 archives. -Verify every download against `checksums.txt` before execution. - -Migration guidance: -[v1.0.x to v1.1.0](https://github.com/hypertrial/intentci/blob/v1.1.0/docs/migration-v1.0-to-v1.1.md). -The v1.0.0 and v1.0.1 tags remain immutable historical releases and are not -moved or replaced. diff --git a/docs/releases/v1.1.1.md b/docs/releases/v1.1.1.md deleted file mode 100644 index 2b91bcb..0000000 --- a/docs/releases/v1.1.1.md +++ /dev/null @@ -1,14 +0,0 @@ -# IntentCI v1.1.1 - -IntentCI v1.1.1 is the recommended v1 release. - -This patch prevents probabilistic evidence with no observed confidence from -inheriting the obligation's minimum threshold and incorrectly passing. It also -adds native Go fuzz targets for every v1 §34.5 property invariant and records -peak resident memory in release performance evidence. - -The release preserves valid v1.0.x and v1.1.0 inputs. The v1.1.0 tag remains -immutable but is superseded by v1.1.1. - -The release contains reproducible Linux amd64 and macOS amd64/arm64 archives. -Verify every download against `checksums.txt` before execution. diff --git a/docs/releases/v2.0.0.md b/docs/releases/v2.0.0.md new file mode 100644 index 0000000..180b017 --- /dev/null +++ b/docs/releases/v2.0.0.md @@ -0,0 +1,12 @@ +# IntentCI v2.0.0 + +IntentCI v2 is a deliberate reset: one YAML file and one local command for the +checks affected by current changes. + +It runs matching commands sequentially through login `zsh`, stops on the first +failure, retains no history, and ships as one Apple Silicon macOS binary. + +This release is incompatible with v1. The final evidence-oriented release, +[`v1.1.1`](https://github.com/hypertrial/intentci/releases/tag/v1.1.1), remains +available and unchanged. See the +[migration guide](https://github.com/hypertrial/intentci/blob/v2.0.0/docs/migration-v1-to-v2.md). diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index 3c1e9ac..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,47 +0,0 @@ -# Roadmap - -North-star specification: [`v1.md`](../v1.md). -Current freeze: [`v1.md`](v1.md). - -## Historical (shipped under Product Contract model) - -### v0.1.0 — Local intent gate - -Vertical slice: Product Contract → path impact → local checks → requirement statuses. - -### v0.2.0 — Change Specs, cache, explain - -Change Specs, successful-check cache, `explain`, 100% coverage gate. - -### v0.3.0 — Local workflow hardening - -Hooks, attestations, contract-weakening detection, JUnit parse, waivers. - -### v0.4.0 — Semantic verification - -Optional local/HTTP semantic providers (Product Contract overlay). - -These lines are **superseded** by v1.0.0. See [migration-v0-to-v1.md](migration-v0-to-v1.md). - -## v1.1.0 — Full v1 conformance (released 2026-07-27) - -All acceptance criteria in [v1.md §38](../v1.md) / [acceptance-v1.md](acceptance-v1.md). - -Internal milestones ([v1.md §37](../v1.md)): - -1. Compiler foundation — Markdown → IR, `init` / `compile` / `schema` -2. Verification engine — providers, executor, verdicts, `verify` -3. Report adapters — JUnit/SARIF/JSON providers and reporters, `explain` / `status` -4. Incremental verification — `--changed`, cache -5. Repair loop — packets, bounded agent, `repair` -6. Release readiness — examples, docs, Apache-2.0, binaries - -The v1.0.x tags remain immutable historical releases. v1.1.0 is the first -release blocked on the machine-readable §38 matrix, Linux/macOS release gates, -mutation evidence, performance records, and independent final review. - -## Post-v1 - -Hosted services, OPA-native integration, distributed execution, container -sandboxing, signed evidence bundles, and the other features in v1.md §41 remain -deferred. diff --git a/docs/v0.1.md b/docs/v0.1.md deleted file mode 100644 index c7e6105..0000000 --- a/docs/v0.1.md +++ /dev/null @@ -1,74 +0,0 @@ -# IntentCI v0.1.0 Scope - -This document freezes the v0.1.0 MVP relative to the full product specification in [`v1.md`](../v1.md). - -**Success criterion:** A maintainer can `intentci init`, define one blocking approved requirement mapped to an existing check and paths, run `intentci check` / `intentci verify`, and get requirement-level `PASS` / `FAIL` / `UNVERIFIED` / `UNKNOWN` with stable text, JSON, and exit codes — with no network calls by IntentCI itself. - -## Included functional requirements - -Mapped from [v1.md §30](../v1.md): - -| ID | Requirement | -| --- | --- | -| IC-CONTRACT-001 | Load and validate a versioned Product Contract | -| IC-CONTRACT-002 | Reject duplicate requirement IDs | -| IC-CONTRACT-003 | Enforce only approved requirements | -| IC-IMPACT-001 | Identify changed files using Git | -| IC-IMPACT-002 | Map changed files to requirements through path rules | -| IC-IMPACT-003 | Map changed files to checks through declared inputs | -| IC-IMPACT-004 | Conservatively report uncertain impact | -| IC-IMPACT-005 | Support explicit full verification (`--all`) | -| IC-RUN-001 | Execute arbitrary local commands | -| IC-RUN-002 | Support check dependencies | -| IC-RUN-003 | Run independent checks concurrently | -| IC-RUN-004 | Enforce check timeouts | -| IC-RUN-005 | Stream subprocess output | -| IC-RUN-006 | Preserve stdout and stderr in the result record | -| IC-EVIDENCE-001 | Every affected requirement receives a status | -| IC-EVIDENCE-002 | Every non-pass status includes a reason | -| IC-EVIDENCE-003 | PASS requires all mandatory evidence | -| IC-EVIDENCE-004 | Missing evidence produces UNVERIFIED | -| IC-EVIDENCE-005 | Tool or execution uncertainty produces UNKNOWN | -| IC-AGENT-001 | Stable JSON output | -| IC-AGENT-002 | Stable exit codes | -| IC-AGENT-003 | Findings identify concrete completion conditions | -| IC-AGENT-004 | No agent-specific adapters | -| IC-GIT-001 | Compare against the Git merge base | -| IC-GIT-002 | Support dirty working trees | - -## Excluded from v0.1.0 - -| Area | Deferred IDs / features | -| --- | --- | -| Change Specs | IC-CHANGE-001..004 | -| Contract weakening | IC-CONTRACT-004..005 | -| Caching | IC-CACHE-001..004 | -| Semantic verification | IC-SEMANTIC-001..005, IC-EVIDENCE-006 | -| Git hooks | IC-GIT-003..004 | -| Attestations | Attestation format and `--attest` | -| JUnit parsing | IC-RUN-007 | -| Other CLI | `explain`, `change create`, `hook install/uninstall` | -| Fixtures | Python, TypeScript, Rust golden repos | -| Platforms | Native Windows (WSL is acceptable) | - -## CLI surface - -```text -intentci init -intentci validate -intentci check [--base ] [--all] [--format text|json] [--output ] [--trust] -intentci verify [--base ] [--all] [--format text|json] [--output ] [--trust] -intentci version -``` - -## Exit codes - -| Code | Meaning | -| --- | --- | -| `0` | Verification passed | -| `10` | One or more blocking requirements failed | -| `11` | One or more blocking requirements are unverified | -| `12` | One or more blocking requirements are unknown | -| `20` | Invalid Product Contract | -| `21` | Missing prerequisite, tool, or base reference | -| `30` | IntentCI internal error | diff --git a/docs/v0.2.md b/docs/v0.2.md deleted file mode 100644 index c93c307..0000000 --- a/docs/v0.2.md +++ /dev/null @@ -1,40 +0,0 @@ -# IntentCI v0.2.0 Scope - -Relative to [`v0.1.md`](v0.1.md) and the north-star [`v1.md`](../v1.md). - -**Success criterion:** A maintainer can create an approved Change Spec, run `check`/`verify` with `--change` and `--no-cache`, see acceptance criteria as temporary requirement results with cache hits on unchanged successful checks, and inspect a requirement via `intentci explain` — with CI-enforced 100% statement coverage. - -## Added in v0.2.0 - -| Area | Behavior | -| --- | --- | -| Change Specs | `intentci change create `, `--change `, AC → temp requirements, forced impact, approved Change Spec mutation findings (IC-CHANGE-001..004) | -| Cache | Success-only content-addressed cache under `~/.cache/intentci/`, `--no-cache` (IC-CACHE-001..004) | -| Explain | `intentci explain ` with optional `--change` for `AC-*` | -| Validate | Validates Product Contract and all `.intentci/changes/*.yaml` | -| Coverage | `scripts/check-coverage.sh` requires 100.0% statements for `./...` | - -## CLI surface - -```text -intentci init -intentci validate -intentci change create -intentci check [--base] [--change] [--all] [--no-cache] [--format text|json] [--output] [--trust] -intentci verify [--base] [--change] [--all] [--no-cache] [--format text|json] [--output] [--trust] -intentci explain [--change] [--base] -intentci version -``` - -`change create` scaffolds acceptance checks using the first check id from the Product Contract when present; otherwise `unit-tests`. - -Change Spec notes: - -- Filename id must match YAML `id` (`.intentci/changes/.yaml`). -- `affected_requirements` must reference approved, blocking Product Contract requirements. -- Approved Change Spec mutations (including demotion away from `approved`) appear in `change_findings`, compared against the merge-base tree. -- `intentci explain` prefers a Product Contract requirement when the id exists there; otherwise `AC-*` requires `--change`. - -## Still deferred (at v0.2 freeze) - -Hooks, attestations, contract-weakening vs base contract, JUnit, semantic providers, multi-language fixtures, waiver enforcement. See [v0.3.md](v0.3.md) for the hardening slice that lands these (except semantic / multi-language). diff --git a/docs/v0.3.md b/docs/v0.3.md deleted file mode 100644 index c9aad21..0000000 --- a/docs/v0.3.md +++ /dev/null @@ -1,42 +0,0 @@ -# IntentCI v0.3.0 Scope - -Relative to [`v0.2.md`](v0.2.md) and the north-star [`v1.md`](../v1.md). - -**Success criterion:** A maintainer can install a managed pre-push hook, run `intentci verify --attest`, see contract weakenings vs the merge-base with effective base policy enforced, apply validated Change Spec waivers, optionally parse JUnit check results, and receive a PASS-only attestation — with CI-enforced 100% statement coverage. - -## Added in v0.3.0 - -| Area | Behavior | -| --- | --- | -| Hooks | `intentci hook install` / `uninstall` with `# BEGIN INTENTCI` / `# END INTENTCI` markers (IC-GIT-003/004) | -| Attestations | `verify --attest` writes `.intentci/tmp/attestation-.json` on PASS only | -| Contract protection | Merge-base contract diff + effective policy (IC-CONTRACT-004/005); `type: contract` Change Spec is the approval mechanism | -| JUnit | Optional `checks[].results.format: junit` parsing (IC-RUN-007) | -| Waivers | Typed Change Spec waivers with expiry/owner/reason; requirement status `waived` | - -## CLI surface - -```text -intentci init -intentci validate -intentci change create -intentci check [--base] [--change] [--all] [--no-cache] [--format text|json] [--output] [--trust] -intentci verify [--base] [--change] [--all] [--no-cache] [--format text|json] [--output] [--trust] [--attest] -intentci explain [--change] [--base] -intentci hook install -intentci hook uninstall -intentci version -``` - -## Locked decisions - -- Waivers live on Change Specs only (not the Product Contract) and apply only when the Change Spec is `approved`. -- Contract weakenings always appear in `contract_changes`. Without an approved Change Spec of `type: contract`, weakenings force overall `unverified` (exit 11) even when checks pass. -- Effective policy retains base approved+blocking requirements/checks that head removes or weakens, and restores stricter `unknown_blocks` / `unverified_blocks` from base. -- `applies_to` narrowing is detected for strict include subsets and exclude growth; broadening to `**` / empty include, or unrelated pattern rewrites, are not treated as narrowing. -- `--attest` is available on `verify` only; dirty trees are allowed and recorded. Attestations are skipped when overall status is not PASS or any check record is fail/unknown (including waiver-driven PASS). -- Hooks never silently overwrite an unmanaged `pre-push` script. The hook runs `intentci verify --attest` and requires the repo to already be trusted (or use `--trust` interactively first). Git hooks are bypassable (`git push --no-verify`). - -## Still deferred (at v0.3 freeze) - -Semantic providers (see [v0.4.md](v0.4.md)), multi-language fixtures, signed attestations, remote attestation validation, Windows native. diff --git a/docs/v0.4.md b/docs/v0.4.md deleted file mode 100644 index 4f20381..0000000 --- a/docs/v0.4.md +++ /dev/null @@ -1,47 +0,0 @@ -# IntentCI v0.4.0 Scope - -Relative to [`v0.3.md`](v0.3.md) and the north-star [`v1.md`](../v1.md). - -**Success criterion:** A maintainer can opt into semantic verification via `policy.semantic`, configure a local executable or HTTP JSON provider, run `intentci verify` / `intentci check` with structured semantic findings and evidence citations, inspect the payload with `--show-semantic-input` before any remote call, and rely on deterministic failures always outranking semantic assessments — with CI-enforced 100% statement coverage. - -## Added in v0.4.0 - -| Area | Behavior | -| --- | --- | -| Semantic policy | `policy.semantic.{enabled,enforcement,confidence_threshold,provider}` | -| Providers | Model-neutral local executable (`stdin`/`stdout` JSON) and HTTP JSON endpoint | -| Findings | Structured assessments with path/line evidence and missing-evidence notes | -| Ranking | Deterministic FAIL always wins; semantic never upgrades a status (IC-EVIDENCE-006) | -| Privacy | No IntentCI network by default; remote only when `provider.type: http`; `--show-semantic-input` | -| Explain | Surfaces semantic findings from `.intentci/tmp/last-result.json` | - -## CLI surface - -```text -intentci init -intentci validate -intentci change create -intentci check [--base] [--change] [--all] [--no-cache] [--format text|json] [--output] [--trust] -intentci verify [--base] [--change] [--all] [--no-cache] [--format text|json] [--output] [--trust] [--attest] [--show-semantic-input] -intentci explain [--change] [--base] -intentci hook install -intentci hook uninstall -intentci version -``` - -## Locked decisions - -- Providers are opt-in: `policy.semantic.enabled: true` and a configured `provider` are both required to invoke a provider. -- Provider connection details live in the Product Contract (non-secret). HTTP credentials use `INTENTCI_SEMANTIC_TOKEN` only. -- Default `confidence_threshold` is `0.8` when omitted. -- `--show-semantic-input` is available on `verify` only: builds and prints the request JSON without running checks, requiring trust, or invoking the provider (`check_results` is empty in the preview). -- Advisory enforcement may downgrade PASS → UNVERIFIED; it must not invent FAIL over a deterministic pass. -- Blocking FAIL requires: confidence ≥ threshold, ≥1 path evidence citation, requirement `approved`, and `verification.semantic: required`. -- Required semantic unavailable (missing provider, provider error) yields requirement status `unknown`. -- Local provider execution requires repository trust (same as check commands). HTTP uses the explicit contract URL, separate from `--trust`. -- Semantic providers must not mutate contracts, waivers, or policy. -- Fast and full profiles use the same semantic pass over selected requirements with `verification.semantic != off`. - -## Still deferred - -Multi-language fixtures, signed attestations, remote attestation validation, Windows native, bundled LLM, draft-requirement proposals from semantic findings. diff --git a/docs/v1-conformance.md b/docs/v1-conformance.md deleted file mode 100644 index 7c339f8..0000000 --- a/docs/v1-conformance.md +++ /dev/null @@ -1,59 +0,0 @@ -# v1 normative conformance index - -This index maps every normative section of repository-root -[`v1.md`](../v1.md) to an executable validation control. It complements the -20 product-level §38 acceptance criteria; it does not replace package-level -tests. - -Status is derived from the named command or hosted job. The document contains -no manually asserted pass state. - -| Control | v1.md scope | Executable validation | -| --- | --- | --- | -| V1-S04 | §4 product model and evidence principles | `go test ./internal/verdict ./internal/evidence` | -| V1-S05 | §5 user workflows | `go test ./tests/acceptance -run '^TestV1Acceptance$'` | -| V1-S06 | §6 repository layout | `go test ./internal/initcmd ./internal/evidence` | -| V1-S07 | §7 typed configuration and precedence | `go test ./internal/config` | -| V1-S08 | §8 Markdown requirement format | `go test ./internal/parser ./internal/compiler` | -| V1-S09 | §9 canonical Intent IR | `go test ./internal/compiler ./internal/ir` | -| V1-S10 | §10 obligation model | `go test ./internal/compiler ./internal/verdict` | -| V1-S11 | §11 verifier model and logical expressions | `go test ./internal/compiler ./internal/executor ./internal/verdict` | -| V1-S12 | §12 dependencies and execution graph | `go test ./internal/compiler ./internal/executor` | -| V1-S13 | §13 CLI surface and selector rules | `go test ./internal/cli` | -| V1-S14 | §14 compile command | `go test ./internal/compiler ./internal/cli` | -| V1-S15 | §15 verify command | `go test ./internal/verify ./internal/impact ./internal/cli` | -| V1-S16 | §16 explain, status, and doctor | `go test ./internal/cli` | -| V1-S17 | §17 verdict and exit-code contract | `go test ./internal/verdict ./internal/exitcode ./internal/cli` | -| V1-S18 | §18 built-in providers and adapters | `go test ./internal/provider` | -| V1-S19 | §19 evidence model and schemas | `go test ./internal/evidence ./pkg/schema` | -| V1-S20 | §20 compilation stages, errors, and warnings | `go test ./internal/compiler` | -| V1-S21 | §21 repository state and change impact | `go test ./internal/git ./internal/impact` | -| V1-S22 | §22 execution, environment, retry, scheduling, and cache | `go test ./internal/executor ./internal/provider` | -| V1-S23 | §23 independent verification and protected contracts | `go test ./internal/repair ./internal/security` | -| V1-S24 | §24 bounded repair loop | `go test ./internal/repair` | -| V1-S25 | §25 terminal, JSON, JUnit, and GitHub reports | `go test ./internal/report ./internal/cli` | -| V1-S26 | §26 GitHub Actions integration | `actionlint .github/workflows/*.yml` and `release-validation` hosted jobs | -| V1-S27 | §27 trust, secrets, paths, symlinks, and integrity | `go test ./internal/security ./internal/provider ./internal/evidence ./internal/repair` | -| V1-S28 | §28 performance targets | `./scripts/record_performance.sh` on Linux and macOS | -| V1-S29 | §29 interruption and persistence reliability | `go test ./internal/evidence ./internal/executor ./internal/verify ./internal/repair` | -| V1-S30 | §30 OS, Git, and language compatibility | `./scripts/check_examples.sh`, `./scripts/cross_compile.sh`, and both hosted OS jobs | -| V1-S31 | §31 internal component and persistence boundaries | `go test ./...` | -| V1-S32 | §32 internal Go interface behavior | `go test ./internal/provider ./internal/evidence ./internal/report` | -| V1-S33 | §33 external provider v1 subprocess protocol | `go test ./internal/provider -run 'External'` | -| V1-S34 | §34 unit, golden, integration, E2E, fuzz, mutation, race, and coverage strategy | `./scripts/check_fuzz.sh`; `INTENTCI_RUN_MUTATION=1 ./scripts/validate_v1_release.sh` | -| V1-S35 | §35 functional requirements | `go test ./tests/acceptance -run '^TestV1Acceptance$'` | -| V1-S36 | §36 first-run and error-message UX | `go test ./internal/initcmd ./internal/cli` | -| V1-S37 | §37 milestone exit criteria | staged green PR history plus `./scripts/validate_v1_release.sh` | -| V1-S38 | §38 release acceptance | 20 named subtests and `acceptance-v1.json` | -| V1-S39 | §39 measurable post-release success signals | immutable run evidence, performance records, and release artifacts | -| V1-S40 | §40 risk mitigations | compiler warnings, strict mode, security tests, mutation gate, and bounded repair tests | -| V1-S41 | §41 deferred capabilities | scope audit in `docs/v1.md` and `docs/roadmap.md` | -| V1-S42 | §42 end-to-end example workflow | AC-20 in `tests/acceptance/v1_acceptance_test.go` | -| V1-S43 | §43 product positioning boundary | README and `docs/v1.md` documentation review | -| V1-S44 | §44 final v1 product boundary | complete release validation plus independent final repository review | - -Sections 1–3 describe motivation, goals, and explicit non-goals; they introduce -no additional runtime contract beyond the controls above. Every subheading and -normative bullet in §§4–44 is owned by its section control. A release is blocked -if any referenced test, release-validation stage, required hosted job, or final -review fails. diff --git a/docs/v1.md b/docs/v1.md deleted file mode 100644 index 9188ede..0000000 --- a/docs/v1.md +++ /dev/null @@ -1,40 +0,0 @@ -# IntentCI v1 - -The normative specification is the repository-root [`v1.md`](../v1.md). -This page is the short implementation guide. - -IntentCI owns the chain: - -```text -Requirement → obligation → verifier → evidence → verdict → repair -``` - -Primary artifacts: - -- `.intentci/config.yaml` and optional gitignored `config.local.yaml`; -- `.intentci/requirements/**/*.md`; -- `.intentci/runs//` immutable evidence bundles. - -The v1 CLI is: - -```text -intentci init -intentci compile -intentci verify -intentci explain -intentci repair -intentci status -intentci doctor -intentci schema -intentci version -``` - -v1.1.0 is the first release gated by the complete normative contract. See the -[executable acceptance matrix](acceptance-v1.md), [configuration reference](configuration.md), -[normative conformance index](v1-conformance.md), -[provider protocol](provider-protocol-v1.md), and -[v1.0 migration note](migration-v1.0-to-v1.1.md). - -Explicitly deferred post-v1 features remain out of scope: hosted services, -OPA-native integration, distributed execution, container sandboxing, signed -evidence bundles, IDE/UI integrations, and automatic requirement generation. diff --git a/examples/github-actions/intentci.yml b/examples/github-actions/intentci.yml deleted file mode 100644 index 5b1e72b..0000000 --- a/examples/github-actions/intentci.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: intentci -on: [push, pull_request] -jobs: - verify: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - fetch-depth: 0 - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: "1.23.x" - - name: Install IntentCI - run: go install github.com/hypertrial/intentci/cmd/intentci@v1.1.0 - - name: Verify changed requirements - run: intentci verify --changed --format junit --output intentci-junit.xml - - name: Upload JUnit - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: intentci-junit - path: intentci-junit.xml diff --git a/examples/go/.intentci.yaml b/examples/go/.intentci.yaml new file mode 100644 index 0000000..d42bb99 --- /dev/null +++ b/examples/go/.intentci.yaml @@ -0,0 +1,9 @@ +version: 2 + +checks: + - id: go-tests + intent: Calculator changes must keep Go tests passing. + paths: + - "**/*.go" + - go.mod + run: go test ./... diff --git a/examples/go/.intentci/.gitignore b/examples/go/.intentci/.gitignore deleted file mode 100644 index 33cf9bf..0000000 --- a/examples/go/.intentci/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -runs/ -cache/ -tmp/ -config.local.yaml diff --git a/examples/go/.intentci/config.yaml b/examples/go/.intentci/config.yaml deleted file mode 100644 index 5fa5b47..0000000 --- a/examples/go/.intentci/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -version: 1 -project: - name: go-example -requirements: - paths: - - .intentci/requirements/**/*.md -evidence: - directory: .intentci/runs -telemetry: - enabled: false diff --git a/examples/go/.intentci/requirements/REQ-GO-001.md b/examples/go/.intentci/requirements/REQ-GO-001.md deleted file mode 100644 index 4afc1b1..0000000 --- a/examples/go/.intentci/requirements/REQ-GO-001.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -id: REQ-GO-001 -title: Go calculator adds integers -status: active -priority: required -owners: - - example-maintainers -applies_to: - paths: - - "*.go" ---- - -# Intent - -The Go calculator must add two integers correctly. - -# Obligations - -```yaml -- id: OBL-GO-001 - statement: The Go test suite passes. - required: true - verify: - provider: command - id: go-test - run: "go test ./... && printf 'intentci-ok\n'" - inherit_environment: - - HOME - - GOCACHE - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -``` diff --git a/examples/go/README.md b/examples/go/README.md index 52d8367..02320da 100644 --- a/examples/go/README.md +++ b/examples/go/README.md @@ -1,10 +1,10 @@ # Go example -This tracked project proves a real Go test command through an IntentCI -requirement. +This small repository demonstrates the complete IntentCI v2 configuration: ```bash -go test ./... -intentci compile --strict -intentci verify --all --no-git +intentci +intentci --all ``` + +Changing `calculator.go` or `calculator_test.go` selects the `go-tests` check. diff --git a/examples/java/.gitignore b/examples/java/.gitignore deleted file mode 100644 index e8258ee..0000000 --- a/examples/java/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -build/ -.intentci/runs/ -.intentci/cache/ diff --git a/examples/java/.intentci/config.yaml b/examples/java/.intentci/config.yaml deleted file mode 100644 index 54f99c4..0000000 --- a/examples/java/.intentci/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -version: 1 -project: - name: java-example -requirements: - paths: - - .intentci/requirements/**/*.md -evidence: - directory: .intentci/runs -telemetry: - enabled: false diff --git a/examples/java/.intentci/requirements/REQ-JAVA-001.md b/examples/java/.intentci/requirements/REQ-JAVA-001.md deleted file mode 100644 index f393a95..0000000 --- a/examples/java/.intentci/requirements/REQ-JAVA-001.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -id: REQ-JAVA-001 -title: Java calculator adds integers -status: active -priority: required -owners: - - example-maintainers -applies_to: - paths: - - "src/**/*.java" ---- - -# Intent - -The Java calculator must add two integers correctly. - -# Obligations - -```yaml -- id: OBL-JAVA-001 - statement: The Java sources compile and the test program passes. - required: true - verify: - provider: command - id: java-test - run: "mkdir -p build && javac -d build src/main/java/example/Calculator.java src/test/java/example/CalculatorTest.java && java -cp build example.CalculatorTest" - inherit_environment: - - HOME - - JAVA_HOME - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -``` diff --git a/examples/java/README.md b/examples/java/README.md deleted file mode 100644 index ea5e658..0000000 --- a/examples/java/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Java example - -This example deliberately uses only the JDK, so no build service or dependency -download is required. - -```bash -mkdir -p build -javac -d build src/main/java/example/Calculator.java src/test/java/example/CalculatorTest.java -java -cp build example.CalculatorTest -intentci compile --strict -intentci verify --all --no-git -``` diff --git a/examples/java/src/main/java/example/Calculator.java b/examples/java/src/main/java/example/Calculator.java deleted file mode 100644 index 33414e6..0000000 --- a/examples/java/src/main/java/example/Calculator.java +++ /dev/null @@ -1,9 +0,0 @@ -package example; - -public final class Calculator { - private Calculator() {} - - public static int add(int left, int right) { - return left + right; - } -} diff --git a/examples/java/src/test/java/example/CalculatorTest.java b/examples/java/src/test/java/example/CalculatorTest.java deleted file mode 100644 index ceb6c93..0000000 --- a/examples/java/src/test/java/example/CalculatorTest.java +++ /dev/null @@ -1,10 +0,0 @@ -package example; - -public final class CalculatorTest { - public static void main(String[] args) { - if (Calculator.add(2, 3) != 5) { - throw new AssertionError("2 + 3 must equal 5"); - } - System.out.println("intentci-ok"); - } -} diff --git a/examples/python/.gitignore b/examples/python/.gitignore deleted file mode 100644 index 9b2e17e..0000000 --- a/examples/python/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -__pycache__/ -*.pyc -.intentci/runs/ -.intentci/cache/ diff --git a/examples/python/.intentci/.gitignore b/examples/python/.intentci/.gitignore deleted file mode 100644 index 33cf9bf..0000000 --- a/examples/python/.intentci/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -runs/ -cache/ -tmp/ -config.local.yaml diff --git a/examples/python/.intentci/config.yaml b/examples/python/.intentci/config.yaml deleted file mode 100644 index f00a207..0000000 --- a/examples/python/.intentci/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -version: 1 -project: - name: python-example -requirements: - paths: - - .intentci/requirements/**/*.md -evidence: - directory: .intentci/runs -telemetry: - enabled: false diff --git a/examples/python/.intentci/requirements/REQ-PYTHON-001.md b/examples/python/.intentci/requirements/REQ-PYTHON-001.md deleted file mode 100644 index e98abc0..0000000 --- a/examples/python/.intentci/requirements/REQ-PYTHON-001.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -id: REQ-PYTHON-001 -title: Python calculator adds integers -status: active -priority: required -owners: - - example-maintainers -applies_to: - paths: - - "*.py" ---- - -# Intent - -The Python calculator must add two integers correctly. - -# Obligations - -```yaml -- id: OBL-PYTHON-001 - statement: The Python unit tests pass. - required: true - verify: - provider: command - id: python-test - run: "python3 -m unittest -v && printf 'intentci-ok\n'" - inherit_environment: - - HOME - - PYTHONPATH - - VIRTUAL_ENV - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -``` diff --git a/examples/python/README.md b/examples/python/README.md deleted file mode 100644 index d830408..0000000 --- a/examples/python/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Python example - -This project uses only Python's standard-library test runner. - -```bash -python3 -m unittest -v -intentci compile --strict -intentci verify --all --no-git -``` diff --git a/examples/python/calculator.py b/examples/python/calculator.py deleted file mode 100644 index e2907f6..0000000 --- a/examples/python/calculator.py +++ /dev/null @@ -1,3 +0,0 @@ -def add(left: int, right: int) -> int: - """Return the sum of two integers.""" - return left + right diff --git a/examples/python/test_calculator.py b/examples/python/test_calculator.py deleted file mode 100644 index 02d570c..0000000 --- a/examples/python/test_calculator.py +++ /dev/null @@ -1,12 +0,0 @@ -import unittest - -from calculator import add - - -class CalculatorTest(unittest.TestCase): - def test_add(self) -> None: - self.assertEqual(add(2, 3), 5) - - -if __name__ == "__main__": - unittest.main() diff --git a/examples/rust/.gitignore b/examples/rust/.gitignore deleted file mode 100644 index 5450908..0000000 --- a/examples/rust/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -target/ -.intentci/runs/ -.intentci/cache/ diff --git a/examples/rust/.intentci/config.yaml b/examples/rust/.intentci/config.yaml deleted file mode 100644 index daf1b6f..0000000 --- a/examples/rust/.intentci/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -version: 1 -project: - name: rust-example -requirements: - paths: - - .intentci/requirements/**/*.md -evidence: - directory: .intentci/runs -telemetry: - enabled: false diff --git a/examples/rust/.intentci/requirements/REQ-RUST-001.md b/examples/rust/.intentci/requirements/REQ-RUST-001.md deleted file mode 100644 index d21209d..0000000 --- a/examples/rust/.intentci/requirements/REQ-RUST-001.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -id: REQ-RUST-001 -title: Rust calculator adds integers -status: active -priority: required -owners: - - example-maintainers -applies_to: - paths: - - "src/**/*.rs" ---- - -# Intent - -The Rust calculator must add two integers correctly. - -# Obligations - -```yaml -- id: OBL-RUST-001 - statement: The Rust test suite passes. - required: true - verify: - provider: command - id: cargo-test - run: "cargo test && printf 'intentci-ok\n'" - inherit_environment: - - HOME - - CARGO_HOME - - RUSTUP_HOME - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -``` diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock deleted file mode 100644 index df20761..0000000 --- a/examples/rust/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "intentci-rust-example" -version = "0.1.0" diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml deleted file mode 100644 index b12c214..0000000 --- a/examples/rust/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "intentci-rust-example" -version = "0.1.0" -edition = "2021" - -[lib] -path = "src/lib.rs" diff --git a/examples/rust/README.md b/examples/rust/README.md deleted file mode 100644 index 03285ee..0000000 --- a/examples/rust/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Rust example - -```bash -cargo test -intentci compile --strict -intentci verify --all --no-git -``` diff --git a/examples/rust/src/lib.rs b/examples/rust/src/lib.rs deleted file mode 100644 index fee5b95..0000000 --- a/examples/rust/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -/// Returns the sum of two integers. -pub fn add(left: i32, right: i32) -> i32 { - left + right -} - -#[cfg(test)] -mod tests { - use super::add; - - #[test] - fn adds_integers() { - assert_eq!(add(2, 3), 5); - } -} diff --git a/examples/typescript/.gitignore b/examples/typescript/.gitignore deleted file mode 100644 index 7df6c1a..0000000 --- a/examples/typescript/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -dist/ -.intentci/runs/ -.intentci/cache/ diff --git a/examples/typescript/.intentci/config.yaml b/examples/typescript/.intentci/config.yaml deleted file mode 100644 index a978269..0000000 --- a/examples/typescript/.intentci/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -version: 1 -project: - name: typescript-example -requirements: - paths: - - .intentci/requirements/**/*.md -evidence: - directory: .intentci/runs -telemetry: - enabled: false diff --git a/examples/typescript/.intentci/requirements/REQ-TYPESCRIPT-001.md b/examples/typescript/.intentci/requirements/REQ-TYPESCRIPT-001.md deleted file mode 100644 index 41d5ab9..0000000 --- a/examples/typescript/.intentci/requirements/REQ-TYPESCRIPT-001.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -id: REQ-TYPESCRIPT-001 -title: TypeScript calculator adds integers -status: active -priority: required -owners: - - example-maintainers -applies_to: - paths: - - "src/**/*.ts" - - "test/**/*.mjs" ---- - -# Intent - -The TypeScript calculator must compile and add two integers correctly. - -# Obligations - -```yaml -- id: OBL-TYPESCRIPT-001 - statement: TypeScript compilation and tests pass. - required: true - verify: - provider: command - id: npm-test - run: "npm test && printf 'intentci-ok\n'" - inherit_environment: - - HOME - - NODE_OPTIONS - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -``` diff --git a/examples/typescript/README.md b/examples/typescript/README.md deleted file mode 100644 index 0802d92..0000000 --- a/examples/typescript/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# TypeScript example - -```bash -npm ci -npm test -intentci compile --strict -intentci verify --all --no-git -``` diff --git a/examples/typescript/package-lock.json b/examples/typescript/package-lock.json deleted file mode 100644 index 0b2e0bc..0000000 --- a/examples/typescript/package-lock.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "intentci-typescript-example", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "intentci-typescript-example", - "devDependencies": { - "typescript": "5.9.2" - } - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/examples/typescript/package.json b/examples/typescript/package.json deleted file mode 100644 index a335026..0000000 --- a/examples/typescript/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "intentci-typescript-example", - "private": true, - "type": "module", - "scripts": { - "build": "tsc", - "test": "tsc && node --test test/calculator.test.mjs" - }, - "devDependencies": { - "typescript": "5.9.2" - } -} diff --git a/examples/typescript/src/calculator.ts b/examples/typescript/src/calculator.ts deleted file mode 100644 index 5d18a4e..0000000 --- a/examples/typescript/src/calculator.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function add(left: number, right: number): number { - return left + right; -} diff --git a/examples/typescript/test/calculator.test.mjs b/examples/typescript/test/calculator.test.mjs deleted file mode 100644 index 4e3f8b3..0000000 --- a/examples/typescript/test/calculator.test.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { add } from "../dist/calculator.js"; - -test("add returns the sum", () => { - assert.equal(add(2, 3), 5); -}); diff --git a/examples/typescript/tsconfig.json b/examples/typescript/tsconfig.json deleted file mode 100644 index db7f637..0000000 --- a/examples/typescript/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "strict": true - }, - "include": ["src/**/*.ts"] -} diff --git a/fixtures/failing-typescript/README.md b/fixtures/failing-typescript/README.md deleted file mode 100644 index 06accf2..0000000 --- a/fixtures/failing-typescript/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Failing TypeScript fixture - -The implementation deliberately subtracts. Integration tests use this fixture -to prove that a real TypeScript test failure cannot become a passing verdict. diff --git a/fixtures/failing-typescript/package-lock.json b/fixtures/failing-typescript/package-lock.json deleted file mode 100644 index 881a2f9..0000000 --- a/fixtures/failing-typescript/package-lock.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "intentci-failing-typescript-fixture", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "intentci-failing-typescript-fixture", - "devDependencies": { - "typescript": "5.9.2" - } - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/fixtures/failing-typescript/package.json b/fixtures/failing-typescript/package.json deleted file mode 100644 index 05ce170..0000000 --- a/fixtures/failing-typescript/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "intentci-failing-typescript-fixture", - "private": true, - "type": "module", - "scripts": { - "test": "tsc && node --test test/calculator.test.mjs" - }, - "devDependencies": { - "typescript": "5.9.2" - } -} diff --git a/fixtures/failing-typescript/src/calculator.ts b/fixtures/failing-typescript/src/calculator.ts deleted file mode 100644 index d460ac7..0000000 --- a/fixtures/failing-typescript/src/calculator.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function add(left: number, right: number): number { - return left - right; -} diff --git a/fixtures/failing-typescript/test/calculator.test.mjs b/fixtures/failing-typescript/test/calculator.test.mjs deleted file mode 100644 index 4e3f8b3..0000000 --- a/fixtures/failing-typescript/test/calculator.test.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { add } from "../dist/calculator.js"; - -test("add returns the sum", () => { - assert.equal(add(2, 3), 5); -}); diff --git a/fixtures/failing-typescript/tsconfig.json b/fixtures/failing-typescript/tsconfig.json deleted file mode 100644 index db7f637..0000000 --- a/fixtures/failing-typescript/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "strict": true - }, - "include": ["src/**/*.ts"] -} diff --git a/fixtures/repair-go/.intentci/.gitignore b/fixtures/repair-go/.intentci/.gitignore deleted file mode 100644 index 33cf9bf..0000000 --- a/fixtures/repair-go/.intentci/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -runs/ -cache/ -tmp/ -config.local.yaml diff --git a/fixtures/repair-go/.intentci/config.yaml b/fixtures/repair-go/.intentci/config.yaml deleted file mode 100644 index 94a1e83..0000000 --- a/fixtures/repair-go/.intentci/config.yaml +++ /dev/null @@ -1,29 +0,0 @@ -version: 1 -project: - name: repair-go-fixture -requirements: - paths: - - .intentci/requirements/**/*.md -verification: - default_timeout: 2m - max_parallel: 2 -change_impact: - base_ref: HEAD - include_untracked: true -evidence: - directory: .intentci/runs -repair: - max_attempts: 2 - stop_on_repeated_diff: true - stop_on_repeated_failure: true - allow_requirement_changes: false - allow_test_changes: false -ci: - fail_on: - - fail - - error - - unproven - - uncertain - - review_required -telemetry: - enabled: false diff --git a/fixtures/repair-go/.intentci/requirements/REQ-REPAIR-001.md b/fixtures/repair-go/.intentci/requirements/REQ-REPAIR-001.md deleted file mode 100644 index fdf5694..0000000 --- a/fixtures/repair-go/.intentci/requirements/REQ-REPAIR-001.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -id: REQ-REPAIR-001 -title: Counter adds integers -status: active -priority: required -owners: - - fixture-maintainers -applies_to: - paths: - - counter.go - - counter_test.go ---- - -# Intent - -The counter package must add two integers correctly. - -# Boundaries - -```yaml -allowed: - - counter.go -forbidden: - - counter_test.go -``` - -# Obligations - -```yaml -- id: OBL-REPAIR-001 - statement: The Go test suite passes. - required: true - verify: - provider: command - id: go-test - run: "go test ./... && printf 'intentci-ok\n'" - inherit_environment: - - HOME - - GOCACHE - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -``` diff --git a/fixtures/repair-go/README.md b/fixtures/repair-go/README.md deleted file mode 100644 index aa2bb10..0000000 --- a/fixtures/repair-go/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Deterministic repair fixture - -The committed implementation is intentionally wrong. The tracked fake agent -copies `repair/counter.fixed` over `counter.go`; IntentCI must independently -verify the second immutable attempt. diff --git a/fixtures/repair-go/counter.go b/fixtures/repair-go/counter.go deleted file mode 100644 index 4565dce..0000000 --- a/fixtures/repair-go/counter.go +++ /dev/null @@ -1,6 +0,0 @@ -package counter - -// Add is intentionally incorrect before the repair agent runs. -func Add(left, right int) int { - return left - right -} diff --git a/fixtures/repair-go/counter_test.go b/fixtures/repair-go/counter_test.go deleted file mode 100644 index b9080a5..0000000 --- a/fixtures/repair-go/counter_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package counter - -import "testing" - -func TestAdd(t *testing.T) { - if got := Add(2, 3); got != 5 { - t.Fatalf("Add(2, 3) = %d, want 5", got) - } -} diff --git a/fixtures/repair-go/go.mod b/fixtures/repair-go/go.mod deleted file mode 100644 index a9adff5..0000000 --- a/fixtures/repair-go/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module example.com/intentci-repair-fixture - -go 1.23 diff --git a/fixtures/repair-go/repair/counter.fixed b/fixtures/repair-go/repair/counter.fixed deleted file mode 100644 index 1beb98e..0000000 --- a/fixtures/repair-go/repair/counter.fixed +++ /dev/null @@ -1,6 +0,0 @@ -package counter - -// Add returns the sum of two integers. -func Add(left, right int) int { - return left + right -} diff --git a/fixtures/repair-go/repair/fake-agent.sh b/fixtures/repair-go/repair/fake-agent.sh deleted file mode 100755 index c8c0944..0000000 --- a/fixtures/repair-go/repair/fake-agent.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env sh -set -eu - -test -f "$1" -cp repair/counter.fixed counter.go diff --git a/fixtures/reports/junit-failure.xml b/fixtures/reports/junit-failure.xml deleted file mode 100644 index 0b3ea65..0000000 --- a/fixtures/reports/junit-failure.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - got -1 - - - diff --git a/fixtures/reports/result.json b/fixtures/reports/result.json deleted file mode 100644 index 33ae348..0000000 --- a/fixtures/reports/result.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "metrics": { - "coverage": 91.5, - "violations": [] - } -} diff --git a/fixtures/reports/sarif-error.json b/fixtures/reports/sarif-error.json deleted file mode 100644 index e45a703..0000000 --- a/fixtures/reports/sarif-error.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": "2.1.0", - "$schema": "https://json.schemastore.org/sarif-2.1.0.json", - "runs": [ - { - "tool": { - "driver": { - "name": "intentci-fixture" - } - }, - "results": [ - { - "ruleId": "FIXTURE001", - "level": "error", - "message": { - "text": "fixture finding" - } - } - ] - } - ] -} diff --git a/go.mod b/go.mod index 53d8127..4e8b896 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,8 @@ -module github.com/hypertrial/intentci +module github.com/hypertrial/intentci/v2 go 1.23 require ( github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/oklog/ulid/v2 v2.1.2 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) - -require ( - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/text v0.14.0 // indirect -) diff --git a/go.sum b/go.sum index 91f6500..1e256c4 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,5 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= -github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..c934ae0 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,314 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/bmatcuk/doublestar/v4" + "gopkg.in/yaml.v3" + + "github.com/hypertrial/intentci/v2/internal/config" + "github.com/hypertrial/intentci/v2/internal/repo" + "github.com/hypertrial/intentci/v2/internal/version" +) + +const usage = `Usage: + intentci + intentci --all + intentci init + intentci version + intentci --help +` + +var shellPath = "/bin/zsh" + +func Main(args []string, stdout, stderr io.Writer) int { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + + var signalCode atomic.Int32 + done := make(chan struct{}) + go func() { + select { + case received := <-signals: + signalCode.Store(int32(exitCodeForSignal(received))) + cancel() + case <-done: + } + }() + + code := Run(ctx, args, stdout, stderr) + close(done) + if interrupted := signalCode.Load(); interrupted != 0 { + return int(interrupted) + } + return code +} + +func Run(ctx context.Context, args []string, stdout, stderr io.Writer) int { + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintln(stderr, "intentci:", err) + return 2 + } + return RunFrom(ctx, cwd, args, stdout, stderr) +} + +func RunFrom(ctx context.Context, start string, args []string, stdout, stderr io.Writer) int { + switch { + case len(args) == 1 && (args[0] == "--help" || args[0] == "-h"): + fmt.Fprint(stdout, usage) + return 0 + case len(args) == 1 && args[0] == "version": + fmt.Fprintln(stdout, version.String()) + return 0 + } + all := len(args) == 1 && args[0] == "--all" + initMode := len(args) == 1 && args[0] == "init" + if len(args) != 0 && !all && !initMode { + fmt.Fprintln(stderr, "intentci: unsupported arguments:", strings.Join(args, " ")) + fmt.Fprint(stderr, usage) + return 2 + } + + root, err := repo.Root(start) + if err != nil { + fmt.Fprintln(stderr, "intentci:", err) + return 2 + } + if initMode { + if err := initialize(root); err != nil { + fmt.Fprintln(stderr, "intentci:", err) + return 2 + } + fmt.Fprintln(stdout, "Created", filepath.Join(root, config.FileName)) + return 0 + } + + cfg, err := config.Load(root) + if err != nil { + fmt.Fprintln(stderr, "intentci:", err) + return 2 + } + + checks := cfg.Checks + if !all { + files, err := repo.Changed(root) + if err != nil { + fmt.Fprintln(stderr, "intentci:", err) + return 2 + } + if len(files) == 0 { + fmt.Fprintln(stdout, "No changes; nothing to run.") + return 0 + } + checks = selectChecks(cfg.Checks, files) + if len(checks) == 0 { + fmt.Fprintf(stdout, "No checks match %d changed file(s).\n", len(files)) + return 0 + } + fmt.Fprintf(stdout, "%d changed file(s); %d check(s) selected.\n", len(files), len(checks)) + } + + for _, check := range checks { + fmt.Fprintf(stdout, "\nRUN %s — %s\n$ %s\n", check.ID, check.Intent, check.Run) + command := exec.CommandContext(ctx, shellPath, "-lc", check.Run) + command.Dir = root + command.Env = os.Environ() + command.Stdout = stdout + command.Stderr = stderr + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { + err := syscall.Kill(-command.Process.Pid, syscall.SIGTERM) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err + } + command.WaitDelay = time.Second + err := command.Run() + if ctx.Err() != nil { + fmt.Fprintf(stderr, "INTERRUPTED %s\n", check.ID) + return 130 + } + if err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + fmt.Fprintf(stderr, "FAIL %s (exit %d)\n", check.ID, exitError.ExitCode()) + return 1 + } + fmt.Fprintf(stderr, "intentci: start %s: %v\n", check.ID, err) + return 2 + } + fmt.Fprintf(stdout, "PASS %s\n", check.ID) + } + fmt.Fprintf(stdout, "\nPASS %d check(s)\n", len(checks)) + return 0 +} + +func exitCodeForSignal(received os.Signal) int { + if received == syscall.SIGTERM { + return 143 + } + return 130 +} + +func selectChecks(checks []config.Check, files []string) []config.Check { + for _, file := range files { + if file == config.FileName { + return append([]config.Check(nil), checks...) + } + } + var selected []config.Check + for _, check := range checks { + matched := false + for _, pattern := range check.Paths { + for _, file := range files { + matched, _ = doublestar.Match(pattern, file) + if matched { + break + } + } + if matched { + break + } + } + if matched { + selected = append(selected, check) + } + } + return selected +} + +func initialize(root string) error { + target := filepath.Join(root, config.FileName) + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("%s already exists", config.FileName) + } else if !os.IsNotExist(err) { + return err + } + if _, err := os.Stat(filepath.Join(root, ".intentci", "config.yaml")); err == nil { + return fmt.Errorf("v1 configuration found; migrate it using docs/migration-v1-to-v2.md") + } else if !os.IsNotExist(err) { + return err + } + + generated := config.Config{Version: 2, Checks: []config.Check{detect(root)}} + data, err := yaml.Marshal(generated) + if err != nil { + return err + } + file, err := os.CreateTemp(root, ".intentci-*.tmp") + if err != nil { + return err + } + temporary := file.Name() + defer os.Remove(temporary) + if err := file.Chmod(0o644); err != nil { + file.Close() + return err + } + if _, err := file.Write(data); err != nil { + file.Close() + return err + } + if err := file.Sync(); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + if err := os.Link(temporary, target); err != nil { + return fmt.Errorf("create %s: %w", config.FileName, err) + } + return nil +} + +func detect(root string) config.Check { + exists := func(name string) bool { + info, err := os.Stat(filepath.Join(root, name)) + return err == nil && !info.IsDir() + } + if exists("go.mod") { + return config.Check{ + ID: "go-tests", Intent: "Go changes must keep tests passing.", + Paths: []string{"**/*.go", "go.mod", "go.sum"}, Run: "go test ./...", + } + } + if exists("package.json") { + run := "npm test" + if exists("pnpm-lock.yaml") { + run = "pnpm test" + } else if exists("yarn.lock") { + run = "yarn test" + } + return config.Check{ + ID: "node-tests", Intent: "Node changes must keep tests passing.", + Paths: []string{ + "**/*.js", "**/*.mjs", "**/*.cjs", "**/*.ts", "**/*.tsx", + "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", + }, + Run: run, + } + } + if exists("pyproject.toml") { + run := "python3 -m pytest -q" + if exists("uv.lock") { + run = "uv run pytest -q" + } + return config.Check{ + ID: "python-tests", Intent: "Python changes must keep tests passing.", + Paths: []string{"**/*.py", "pyproject.toml", "requirements*.txt", "uv.lock"}, + Run: run, + } + } + if exists("Cargo.toml") { + return config.Check{ + ID: "rust-tests", Intent: "Rust changes must keep tests passing.", + Paths: []string{"**/*.rs", "Cargo.toml", "Cargo.lock"}, Run: "cargo test", + } + } + if exists("pom.xml") { + run := "mvn test" + if exists("mvnw") { + run = "./mvnw test" + } + return config.Check{ + ID: "java-tests", Intent: "Java changes must keep tests passing.", + Paths: []string{"**/*.java", "pom.xml", ".mvn/**"}, Run: run, + } + } + if exists("build.gradle") || exists("build.gradle.kts") { + run := "gradle test" + if exists("gradlew") { + run = "./gradlew test" + } + return config.Check{ + ID: "java-tests", Intent: "Java changes must keep tests passing.", + Paths: []string{ + "**/*.java", "build.gradle", "build.gradle.kts", + "settings.gradle", "settings.gradle.kts", "gradle/**", "gradle.lockfile", + }, + Run: run, + } + } + return config.Check{ + ID: "tests", Intent: "Repository changes must pass its configured tests.", + Paths: []string{"**"}, + Run: `echo "Edit .intentci.yaml and replace this command." >&2; exit 1`, + } +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..3913d3f --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,301 @@ +package app + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + "time" + + "github.com/hypertrial/intentci/v2/internal/config" +) + +func command(t *testing.T, root string, args ...string) { + t.Helper() + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = root + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, output) + } +} + +func writeFile(t *testing.T, root, name, content string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } +} + +func gitRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + root, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + command(t, root, "git", "init", "-q") + command(t, root, "git", "config", "user.email", "intentci@example.com") + command(t, root, "git", "config", "user.name", "IntentCI") + return root +} + +func commitAll(t *testing.T, root string) { + t.Helper() + command(t, root, "git", "add", ".") + command(t, root, "git", "commit", "-qm", "state") +} + +func runFrom(t *testing.T, ctx context.Context, root string, args ...string) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + code := RunFrom(ctx, root, args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +func TestSelectChecks(t *testing.T) { + checks := []config.Check{ + {ID: "go", Paths: []string{"**/*.go"}}, + {ID: "docs", Paths: []string{"docs/**"}}, + } + selected := selectChecks(checks, []string{"README.md", "x.go", "x.go"}) + if len(selected) != 1 || selected[0].ID != "go" { + t.Fatalf("selected = %#v", selected) + } + if got := selectChecks(checks, []string{"README.md"}); got != nil { + t.Fatalf("selected = %#v, want nil", got) + } + if got := selectChecks(checks, []string{config.FileName}); !reflect.DeepEqual(got, checks) { + t.Fatalf("config selection = %#v", got) + } +} + +func TestDetectStacksAndPriority(t *testing.T) { + tests := []struct { + name string + files []string + id string + run string + }{ + {"go", []string{"go.mod"}, "go-tests", "go test ./..."}, + {"pnpm", []string{"package.json", "pnpm-lock.yaml"}, "node-tests", "pnpm test"}, + {"yarn", []string{"package.json", "yarn.lock"}, "node-tests", "yarn test"}, + {"npm", []string{"package.json"}, "node-tests", "npm test"}, + {"uv", []string{"pyproject.toml", "uv.lock"}, "python-tests", "uv run pytest -q"}, + {"python", []string{"pyproject.toml"}, "python-tests", "python3 -m pytest -q"}, + {"rust", []string{"Cargo.toml"}, "rust-tests", "cargo test"}, + {"maven wrapper", []string{"pom.xml", "mvnw"}, "java-tests", "./mvnw test"}, + {"maven", []string{"pom.xml"}, "java-tests", "mvn test"}, + {"gradle wrapper", []string{"build.gradle", "gradlew"}, "java-tests", "./gradlew test"}, + {"gradle", []string{"build.gradle.kts"}, "java-tests", "gradle test"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + for _, file := range test.files { + writeFile(t, root, file, "") + } + got := detect(root) + if got.ID != test.id || got.Run != test.run { + t.Fatalf("detect() = %s/%q, want %s/%q", got.ID, got.Run, test.id, test.run) + } + }) + } + root := t.TempDir() + writeFile(t, root, "package.json", "{}") + writeFile(t, root, "go.mod", "module example") + if got := detect(root); got.ID != "go-tests" { + t.Fatalf("priority selected %q", got.ID) + } +} + +func TestInitialize(t *testing.T) { + root := gitRepo(t) + if err := initialize(root); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(root) + if err != nil { + t.Fatal(err) + } + if cfg.Checks[0].ID != "tests" || !strings.Contains(cfg.Checks[0].Run, "exit 1") { + t.Fatalf("placeholder = %#v", cfg.Checks[0]) + } + if err := initialize(root); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("second initialize error = %v", err) + } + + v1 := gitRepo(t) + writeFile(t, v1, ".intentci/config.yaml", "version: 1") + if err := initialize(v1); err == nil || !strings.Contains(err.Error(), "v1 configuration") { + t.Fatalf("v1 initialize error = %v", err) + } +} + +func TestCLIHelpVersionAndUsage(t *testing.T) { + code, out, _ := runFrom(t, context.Background(), t.TempDir(), "--help") + if code != 0 || !strings.Contains(out, "intentci --all") { + t.Fatalf("help = %d, %q", code, out) + } + code, out, _ = runFrom(t, context.Background(), t.TempDir(), "version") + if code != 0 || strings.TrimSpace(out) != "2.0.0" { + t.Fatalf("version = %d, %q", code, out) + } + root := gitRepo(t) + code, _, stderr := runFrom(t, context.Background(), root, "verify") + if code != 2 || !strings.Contains(stderr, "unsupported arguments") { + t.Fatalf("usage = %d, %q", code, stderr) + } + code, _, stderr = runFrom(t, context.Background(), t.TempDir()) + if code != 2 || !strings.Contains(stderr, "not a Git repository") { + t.Fatalf("non-repo = %d, %q", code, stderr) + } +} + +func TestInitAndChangedFileWorkflow(t *testing.T) { + root := gitRepo(t) + writeFile(t, root, "go.mod", "module example\n\ngo 1.23\n") + code, stdout, stderr := runFrom(t, context.Background(), root, "init") + if code != 0 { + t.Fatalf("init = %d\n%s\n%s", code, stdout, stderr) + } + writeFile(t, root, "main.go", "package example\n") + commitAll(t, root) + + code, stdout, stderr = runFrom(t, context.Background(), filepath.Join(root, ".")) + if code != 0 || !strings.Contains(stdout, "No changes") { + t.Fatalf("clean = %d\n%s\n%s", code, stdout, stderr) + } + code, stdout, stderr = runFrom(t, context.Background(), root, "--all") + if code != 0 || !strings.Contains(stdout, "RUN go-tests") { + t.Fatalf("clean all = %d\n%s\n%s", code, stdout, stderr) + } + writeFile(t, root, "README.md", "unrelated") + code, stdout, stderr = runFrom(t, context.Background(), root) + if code != 0 || !strings.Contains(stdout, "No checks match") { + t.Fatalf("unrelated = %d\n%s\n%s", code, stdout, stderr) + } + writeFile(t, root, "main.go", "package example\n\nconst Changed = true\n") + subdir := filepath.Join(root, "nested") + if err := os.Mkdir(subdir, 0o755); err != nil { + t.Fatal(err) + } + code, stdout, stderr = runFrom(t, context.Background(), subdir) + if code != 0 || !strings.Contains(stdout, "RUN go-tests") || !strings.Contains(stdout, "PASS 1 check") { + t.Fatalf("changed = %d\n%s\n%s", code, stdout, stderr) + } +} + +func TestMatchingEnvironmentRootAndAll(t *testing.T) { + root := gitRepo(t) + writeFile(t, root, config.FileName, `version: 2 +checks: + - id: source + intent: Source check. + paths: ["src/**"] + run: test "$INTENTCI_TEST_VALUE" = inherited && pwd > where.txt + - id: docs + intent: Docs check. + paths: ["docs/**"] + run: echo docs +`) + writeFile(t, root, "src/file.txt", "initial") + commitAll(t, root) + writeFile(t, root, "src/file.txt", "changed") + t.Setenv("INTENTCI_TEST_VALUE", "inherited") + + code, stdout, stderr := runFrom(t, context.Background(), root) + if code != 0 || strings.Contains(stdout, "RUN docs") { + t.Fatalf("matching = %d\n%s\n%s", code, stdout, stderr) + } + where, err := os.ReadFile(filepath.Join(root, "where.txt")) + if err != nil || strings.TrimSpace(string(where)) != root { + t.Fatalf("working directory = %q, %v; want %q", where, err, root) + } + code, stdout, stderr = runFrom(t, context.Background(), root, "--all") + if code != 0 || !strings.Contains(stdout, "RUN source") || !strings.Contains(stdout, "RUN docs") { + t.Fatalf("all = %d\n%s\n%s", code, stdout, stderr) + } +} + +func TestFailFastInvalidConfigAndLaunchFailure(t *testing.T) { + root := gitRepo(t) + writeFile(t, root, config.FileName, `version: 2 +checks: + - id: fail + intent: This fails. + paths: ["**"] + run: exit 7 + - id: later + intent: This must not run. + paths: ["**"] + run: touch later +`) + code, _, stderr := runFrom(t, context.Background(), root, "--all") + if code != 1 || !strings.Contains(stderr, "FAIL fail (exit 7)") { + t.Fatalf("failure = %d, %q", code, stderr) + } + if _, err := os.Stat(filepath.Join(root, "later")); !os.IsNotExist(err) { + t.Fatalf("later check ran: %v", err) + } + + writeFile(t, root, config.FileName, "version: 2\nchecks: []\n") + code, _, stderr = runFrom(t, context.Background(), root, "--all") + if code != 2 || !strings.Contains(stderr, "checks must not be empty") { + t.Fatalf("invalid config = %d, %q", code, stderr) + } + + writeFile(t, root, config.FileName, `version: 2 +checks: + - id: launch + intent: Shell starts. + paths: ["**"] + run: "true" +`) + oldShell := shellPath + shellPath = filepath.Join(root, "missing-zsh") + defer func() { shellPath = oldShell }() + code, _, stderr = runFrom(t, context.Background(), root, "--all") + if code != 2 || !strings.Contains(stderr, "start launch") { + t.Fatalf("launch = %d, %q", code, stderr) + } +} + +func TestCancellationAndSignalExitCodes(t *testing.T) { + root := gitRepo(t) + writeFile(t, root, config.FileName, `version: 2 +checks: + - id: wait + intent: Waits. + paths: ["**"] + run: sleep 0.3; touch child-survived + - id: later + intent: Must not run. + paths: ["**"] + run: touch later +`) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + code, _, stderr := runFrom(t, ctx, root, "--all") + if code != 130 || !strings.Contains(stderr, "INTERRUPTED wait") { + t.Fatalf("cancel = %d, %q", code, stderr) + } + if _, err := os.Stat(filepath.Join(root, "later")); !os.IsNotExist(err) { + t.Fatalf("later check ran: %v", err) + } + time.Sleep(400 * time.Millisecond) + if _, err := os.Stat(filepath.Join(root, "child-survived")); !os.IsNotExist(err) { + t.Fatalf("child process survived cancellation: %v", err) + } + if exitCodeForSignal(os.Interrupt) != 130 || exitCodeForSignal(syscall.SIGTERM) != 143 { + t.Fatal("signal exit mapping changed") + } +} diff --git a/internal/cli/cli_coverage_test.go b/internal/cli/cli_coverage_test.go deleted file mode 100644 index cdc2fea..0000000 --- a/internal/cli/cli_coverage_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package cli_test - -import ( - "bytes" - "errors" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/cli" - "github.com/hypertrial/intentci/internal/exitcode" -) - -func TestRunMainExitErrorAndGeneric(t *testing.T) { - var out, errb bytes.Buffer - code := cli.RunMain([]string{"schema", "nope"}, &out, &errb) - if code != exitcode.Usage { - t.Fatalf("code=%d err=%s", code, errb.String()) - } - code = cli.RunMain([]string{"not-a-command"}, &out, &errb) - if code != exitcode.Internal && code != 1 { - // cobra unknown command returns error -> Internal - _ = code - } -} - -func TestCLIFlagsAndErrorPaths(t *testing.T) { - root := t.TempDir() - gitInit(t, root) - old, _ := os.Getwd() - defer os.Chdir(old) - if err := os.Chdir(root); err != nil { - t.Fatal(err) - } - - var out, errb bytes.Buffer - if code := cli.RunMain([]string{"init", "--language", "python", "--ci", "github", "--force"}, &out, &errb); code != 0 { - // first init - _ = code - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"init"}, &out, &errb); code == 0 { - t.Fatal("expected exists") - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"init", "--force", "--no-example"}, &out, &errb); code != 0 { - t.Fatalf("force init %d %s", code, errb.String()) - } - // restore example for compile/verify - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"init", "--force"}, &out, &errb); code != 0 { - t.Fatal(code, errb.String()) - } - gitInit(t, root) - - out.Reset() - errb.Reset() - outPath := filepath.Join(root, "ir.json") - if code := cli.RunMain([]string{"compile", "--format", "json", "--output", outPath, "--requirement", "REQ-001"}, &out, &errb); code != 0 { - t.Fatalf("compile %d %s", code, errb.String()) - } - - out.Reset() - errb.Reset() - rep := filepath.Join(root, "report.json") - if code := cli.RunMain([]string{"verify", "--all", "--format", "junit", "--output", rep, "--no-cache"}, &out, &errb); code != 0 { - t.Fatalf("verify %d %s", code, errb.String()) - } - - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"explain", "REQ-001", "--format", "json", "--show-logs"}, &out, &errb); code != 0 { - t.Fatalf("explain json %d %s", code, errb.String()) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"explain", "MISSING", "--format", "json"}, &out, &errb); code == 0 { - t.Fatal("expected missing") - } - // load by run id - latest, _ := os.ReadFile(filepath.Join(root, ".intentci", "runs", "latest")) - runID := string(bytes.TrimSpace(latest)) - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"explain", "REQ-001", "--run", runID, "--show-evidence"}, &out, &errb); code != 0 { - t.Fatalf("explain run %d %s", code, errb.String()) - } - - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"repair", "--dry-run", "--max-attempts", "1", "--allow-test-changes"}, &out, &errb); code != exitcode.Pass && code != exitcode.RepairExhausted { - // may pass - _ = code - } - - for _, name := range []string{"requirement", "evidence", "verdict", "repair", "intent"} { - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"schema", name}, &out, &errb); code != 0 || out.Len() == 0 { - t.Fatalf("schema %s code=%d", name, code) - } - } - - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"verify", "--changed", "--base", "HEAD", "--format", "text"}, &out, &errb); code != 0 && code != exitcode.Pass { - _ = code - } - - // status without run in fresh dir - empty := t.TempDir() - gitInit(t, empty) - if err := os.Chdir(empty); err != nil { - t.Fatal(err) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"status"}, &out, &errb); code == 0 { - t.Fatal("expected no runs") - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"doctor"}, &out, &errb); code == 0 { - t.Fatal("doctor should fail without config") - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"explain", "X"}, &out, &errb); code == 0 { - t.Fatal("no run") - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"repair", "--dry-run"}, &out, &errb); code == 0 { - t.Fatal("no config") - } - _ = errors.New("keep import") -} diff --git a/internal/cli/cli_final_internal_test.go b/internal/cli/cli_final_internal_test.go deleted file mode 100644 index 6d055e4..0000000 --- a/internal/cli/cli_final_internal_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package cli - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/initcmd" -) - -func TestVerifyDefaultChangedAndRepairStopped(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - run := func(args ...string) { - t.Helper() - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } - run("git", "init") - run("git", "config", "user.email", "t@e.com") - run("git", "config", "user.name", "t") - run("git", "add", ".") - run("git", "commit", "-m", "i") - - old := getwd - defer func() { getwd = old }() - getwd = func() (string, error) { return root, nil } - - var out, errb bytes.Buffer - // default changed=true (no --all/--changed/--requirement) - _ = RunMain([]string{"verify", "--base", "HEAD", "--no-cache"}, &out, &errb) - - // verify error with outcome exit code (compile failed) - if err := os.WriteFile(filepath.Join(root, ".intentci", "requirements", "REQ-001.md"), []byte("bad"), 0o644); err != nil { - t.Fatal(err) - } - out.Reset() - errb.Reset() - if code := RunMain([]string{"verify", "--all"}, &out, &errb); code == exitcode.Pass { - t.Fatal("expected compile fail exit") - } - - // restore failing obligation for repair stopped path - req := `--- -id: REQ-001 -title: t -status: active -priority: required ---- -# Intent -i -# Obligations -` + "```yaml" + ` -- id: OBL-001 - statement: fail - required: true - verify: - provider: command - id: smoke - run: "false" - result: {equals: 0} -` + "```" - if err := os.WriteFile(filepath.Join(root, ".intentci", "requirements", "REQ-001.md"), []byte(req), 0o644); err != nil { - t.Fatal(err) - } - run("git", "add", ".intentci/requirements/REQ-001.md") - run("git", "commit", "-m", "failing requirement") - out.Reset() - errb.Reset() - code := RunMain([]string{"repair", "--dry-run", "--max-attempts", "1"}, &out, &errb) - if code == exitcode.Pass { - t.Fatal("expected non-pass repair") - } - if !bytes.Contains(errb.Bytes(), []byte("repair stopped")) && code != exitcode.RepairExhausted && code != exitcode.Fail { - // stopped message printed on stderr when Stopped != "" - _ = errb.String() - } - - // repair verify callback / repair.Run error (config ok, compile fails) - if err := os.WriteFile(filepath.Join(root, ".intentci", "requirements", "REQ-001.md"), []byte("bad"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), []byte(`version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -`), 0o644); err != nil { - t.Fatal(err) - } - run("git", "add", ".intentci") - run("git", "commit", "-m", "invalid requirement") - out.Reset() - errb.Reset() - if code := RunMain([]string{"repair", "--dry-run", "--max-attempts", "1"}, &out, &errb); code == exitcode.Pass { - t.Fatal("expected repair verify error") - } - - // doctor telemetry enabled with clean config - getwd = func() (string, error) { return root, nil } - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), []byte(`version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -telemetry: - enabled: true -`), 0o644); err != nil { - t.Fatal(err) - } - out.Reset() - errb.Reset() - if code := RunMain([]string{"doctor"}, &out, &errb); code == 0 { - t.Fatal("telemetry should fail doctor") - } - if !bytes.Contains(out.Bytes(), []byte("telemetry")) { - t.Fatalf("out=%s", out.String()) - } -} diff --git a/internal/cli/cli_internal_test.go b/internal/cli/cli_internal_test.go deleted file mode 100644 index 74a1386..0000000 --- a/internal/cli/cli_internal_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package cli - -import ( - "bytes" - "errors" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestExitErrorAndMustConfig(t *testing.T) { - e := &ExitError{Code: 1} - if e.Error() != "exit 1" { - t.Fatal(e.Error()) - } - e.Msg = "hi" - if e.Error() != "hi" { - t.Fatal(e.Error()) - } - err := exitErr(2, "x %d", 1) - if err.Error() != "x 1" { - t.Fatal(err) - } - cfg := mustConfig(t.TempDir()) - if cfg.Project.Name == "" { - t.Fatal("default") - } - if short("abcdefghi") != "abcdefg" { - t.Fatal(short("abcdefghi")) - } - if short("abc") != "abc" { - t.Fatal(short("abc")) - } - - old := getwd - defer func() { getwd = old }() - getwd = func() (string, error) { return "", errors.New("cwd") } - var out, errb bytes.Buffer - if code := RunMain([]string{"init"}, &out, &errb); code != exitcode.Internal { - t.Fatalf("code=%d", code) - } - for _, args := range [][]string{ - {"compile"}, {"verify", "--all"}, {"explain", "X"}, {"repair"}, {"status"}, {"doctor"}, - } { - out.Reset() - errb.Reset() - _ = RunMain(args, &out, &errb) - } -} - -func TestStatusVerdictCounts(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, ".intentci"), 0o755); err != nil { - t.Fatal(err) - } - cfg := `version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -` - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), []byte(cfg), 0o644); err != nil { - t.Fatal(err) - } - store, err := evidence.NewStore(root, ".intentci/runs") - if err != nil { - t.Fatal(err) - } - b := &evidence.Bundle{ - RunID: "r", HeadCommit: "abc", - Run: verdict.RunResult{Verdict: verdict.Fail, Requirements: []verdict.RequirementResult{ - {Verdict: "pass"}, {Verdict: "fail"}, {Verdict: "unproven"}, {Verdict: "uncertain"}, {Verdict: "error"}, - }}, - } - if err := store.WriteBundle(b); err != nil { - t.Fatal(err) - } - old := getwd - defer func() { getwd = old }() - getwd = func() (string, error) { return root, nil } - var out, errb bytes.Buffer - if code := RunMain([]string{"status"}, &out, &errb); code != 0 { - t.Fatalf("%d %s", code, errb.String()) - } -} - -func TestRunMainExitErrorType(t *testing.T) { - var errb bytes.Buffer - code := RunMain([]string{"schema", "nope"}, &bytes.Buffer{}, &errb) - if code != exitcode.Usage || errb.Len() == 0 { - t.Fatalf("%d %s", code, errb.String()) - } -} diff --git a/internal/cli/cli_more_internal_test.go b/internal/cli/cli_more_internal_test.go deleted file mode 100644 index d999452..0000000 --- a/internal/cli/cli_more_internal_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package cli - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/initcmd" -) - -func TestCLIErrorBranches(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - runGit := func(args ...string) { - t.Helper() - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } - runGit("git", "init") - runGit("git", "config", "user.email", "t@e.com") - runGit("git", "config", "user.name", "t") - runGit("git", "add", ".") - runGit("git", "commit", "-m", "i") - - old := getwd - defer func() { getwd = old }() - getwd = func() (string, error) { return root, nil } - - var out, errb bytes.Buffer - bad := filepath.Join(root, ".intentci", "requirements", "REQ-001.md") - if err := os.WriteFile(bad, []byte("bad"), 0o644); err != nil { - t.Fatal(err) - } - if code := RunMain([]string{"compile", "--strict"}, &out, &errb); code == 0 { - t.Fatal("compile should fail") - } - if err := initcmd.Run(initcmd.Options{Root: root, Force: true}); err != nil { - t.Fatal(err) - } - - block := filepath.Join(root, "block") - if err := os.WriteFile(block, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - out.Reset() - errb.Reset() - if code := RunMain([]string{"compile", "--output", filepath.Join(block, "ir.json")}, &out, &errb); code == 0 { - t.Fatal("writeir") - } - - out.Reset() - errb.Reset() - if code := RunMain([]string{"verify", "--all", "--output", filepath.Join(block, "out.json")}, &out, &errb); code == 0 { - t.Fatal("output create") - } - - req := `--- -id: REQ-001 -title: Example requirement -status: active -priority: required ---- -# Intent -i -# Obligations -` + "```yaml" + ` -- id: OBL-001 - statement: fail - required: true - verify: - provider: command - id: smoke - run: "false" - result: {equals: 0} -` + "```" - if err := os.WriteFile(filepath.Join(root, ".intentci", "requirements", "REQ-001.md"), []byte(req), 0o644); err != nil { - t.Fatal(err) - } - out.Reset() - errb.Reset() - if code := RunMain([]string{"verify", "--all", "--format", "text", "--no-cache"}, &out, &errb); code == exitcode.Pass { - t.Fatal("expected fail verdict") - } - - out.Reset() - errb.Reset() - _ = RunMain([]string{"verify", "--all", "--format", "nope", "--no-cache"}, &out, &errb) - - out.Reset() - errb.Reset() - _ = RunMain([]string{"explain", "NOPE"}, &out, &errb) - - empty := t.TempDir() - getwd = func() (string, error) { return empty, nil } - out.Reset() - errb.Reset() - _ = RunMain([]string{"repair", "--dry-run", "--max-attempts", "1"}, &out, &errb) - - getwd = func() (string, error) { return root, nil } - cfgPath := filepath.Join(root, ".intentci", "config.yaml") - body, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(cfgPath, append(body, []byte("\ntelemetry:\n enabled: true\n")...), 0o644); err != nil { - t.Fatal(err) - } - oldGOOS := goos - goos = "windows" - defer func() { goos = oldGOOS }() - out.Reset() - errb.Reset() - if code := RunMain([]string{"doctor"}, &out, &errb); code == 0 { - t.Fatal("doctor should fail") - } - - goos = "darwin" - tmp := t.TempDir() - if err := os.MkdirAll(filepath.Join(tmp, ".intentci"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(tmp, ".intentci", "config.yaml"), []byte(`version: 1 -project: {name: d} -requirements: - paths: [".intentci/requirements/**/*.md"] -evidence: - directory: runs -`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(tmp, "runs"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - getwd = func() (string, error) { return tmp, nil } - out.Reset() - errb.Reset() - _ = RunMain([]string{"doctor"}, &out, &errb) - out.Reset() - errb.Reset() - _ = RunMain([]string{"status"}, &out, &errb) - out.Reset() - errb.Reset() - _ = RunMain([]string{"explain", "X"}, &out, &errb) - out.Reset() - errb.Reset() - _ = RunMain([]string{"repair", "--dry-run"}, &out, &errb) - - getwd = func() (string, error) { return root, nil } - out.Reset() - errb.Reset() - _ = RunMain([]string{"repair", "--dry-run", "--max-attempts", "1", "--changed"}, &out, &errb) - - // compile NewStore failure via evidence.directory file - if err := initcmd.Run(initcmd.Options{Root: root, Force: true}); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), []byte(`version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -evidence: - directory: blocked -`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "blocked"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - out.Reset() - errb.Reset() - _ = RunMain([]string{"compile"}, &out, &errb) -} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go deleted file mode 100644 index 045b2df..0000000 --- a/internal/cli/cli_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package cli_test - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/cli" - "github.com/hypertrial/intentci/internal/exitcode" -) - -func gitInit(t *testing.T, dir string) { - t.Helper() - cmds := [][]string{ - {"git", "init"}, - {"git", "config", "user.email", "t@example.com"}, - {"git", "config", "user.name", "t"}, - {"git", "add", "."}, - {"git", "commit", "-m", "init", "--allow-empty"}, - } - for _, c := range cmds { - cmd := exec.Command(c[0], c[1:]...) - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } -} - -func TestInitCompileVerifyExplainStatusSchemaDoctor(t *testing.T) { - root := t.TempDir() - gitInit(t, root) - old, _ := os.Getwd() - defer os.Chdir(old) - if err := os.Chdir(root); err != nil { - t.Fatal(err) - } - - var out, errb bytes.Buffer - if code := cli.RunMain([]string{"init"}, &out, &errb); code != 0 { - t.Fatalf("init code=%d err=%s", code, errb.String()) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"compile", "--strict"}, &out, &errb); code != 0 { - t.Fatalf("compile code=%d err=%s out=%s", code, errb.String(), out.String()) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"verify", "--all", "--format", "json"}, &out, &errb); code != 0 { - t.Fatalf("verify code=%d err=%s out=%s", code, errb.String(), out.String()) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"explain", "REQ-001", "--show-evidence"}, &out, &errb); code != 0 { - t.Fatalf("explain code=%d err=%s", code, errb.String()) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"status"}, &out, &errb); code != 0 { - t.Fatalf("status code=%d err=%s", code, errb.String()) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"schema", "ir"}, &out, &errb); code != 0 || out.Len() == 0 { - t.Fatalf("schema code=%d", code) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"doctor"}, &out, &errb); code != 0 { - t.Fatalf("doctor code=%d err=%s out=%s", code, errb.String(), out.String()) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"version"}, &out, &errb); code != 0 { - t.Fatal(code) - } - out.Reset() - errb.Reset() - if code := cli.RunMain([]string{"repair", "--dry-run", "--max-attempts", "1", "--changed=false"}, &out, &errb); code != exitcode.Pass && code != exitcode.RepairExhausted && code != exitcode.Fail && code != exitcode.Unproven { - // dry-run with passing verify should pass - _ = code - } - // ensure config exists - if _, err := os.Stat(filepath.Join(root, ".intentci", "config.yaml")); err != nil { - t.Fatal(err) - } -} diff --git a/internal/cli/cli_v1_completion_internal_test.go b/internal/cli/cli_v1_completion_internal_test.go deleted file mode 100644 index a7ab5ae..0000000 --- a/internal/cli/cli_v1_completion_internal_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - repogit "github.com/hypertrial/intentci/internal/git" - "github.com/hypertrial/intentci/internal/initcmd" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" - "github.com/hypertrial/intentci/internal/verify" -) - -func TestV1CLIUsageValidationAndAdapterResolution(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - initializeTestGit(t, root) - oldGetwd := getwd - defer func() { getwd = oldGetwd }() - getwd = func() (string, error) { return root, nil } - - usageCases := [][]string{ - {"compile", "--format", "yaml"}, - {"compile", "--requirement", " "}, - {"compile", "--requirement", "MISSING"}, - {"verify", "--all", "--changed"}, - {"verify", "--all", "--max-parallel", "-1"}, - {"verify", "--all", "--requirement", " "}, - {"verify", "--all", "--obligation", " "}, - {"verify", "--all", "--provider", " "}, - {"explain", "REQ-001", "--format", "yaml"}, - {"repair", "--agent", "one", "--agent-command", "true"}, - {"repair", "--max-attempts", "0"}, - {"repair", "--max-attempts", "-1"}, - {"repair", "--requirement", " "}, - {"repair", "--agent", " "}, - {"repair", "--agent-command", " "}, - {"repair", "--agent", "Bad"}, - {"repair", "--agent", "missing"}, - } - for _, args := range usageCases { - var stdout, stderr bytes.Buffer - if code := RunMain(args, &stdout, &stderr); code != exitcode.Usage { - t.Fatalf("%v returned %d, want usage; stderr=%s", args, code, stderr.String()) - } - } - - adapterDir := t.TempDir() - adapter := filepath.Join(adapterDir, "intentci-agent-good") - if err := os.WriteFile(adapter, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", adapterDir+string(os.PathListSeparator)+os.Getenv("PATH")) - var stdout, stderr bytes.Buffer - if code := RunMain([]string{"repair", "--agent", "good", "--dry-run", "--max-attempts", "1"}, &stdout, &stderr); code != exitcode.Pass { - t.Fatalf("resolved adapter returned %d: %s", code, stderr.String()) - } - if code := RunMain([]string{"repair", "--agent-command", "true", "--max-attempts", "1"}, &stdout, &stderr); code != exitcode.Pass { - t.Fatalf("host warning path returned %d: %s", code, stderr.String()) - } - if !strings.Contains(stderr.String(), "not a sandbox") { - t.Fatalf("host execution warning missing: %s", stderr.String()) - } - - for input, want := range map[string]bool{ - "": false, "a": true, "a-0": true, "A": false, "_": false, - } { - if got := validAdapterName(input); got != want { - t.Fatalf("validAdapterName(%q)=%t, want %t", input, got, want) - } - } -} - -func initializeTestGit(t *testing.T, root string) { - t.Helper() - for _, arguments := range [][]string{ - {"init"}, - {"config", "user.email", "intentci@example.test"}, - {"config", "user.name", "IntentCI Test"}, - {"add", "."}, - {"commit", "-m", "initial"}, - } { - command := exec.Command("git", arguments...) - command.Dir = root - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v\n%s", arguments, err, output) - } - } -} - -type fakeReportFile struct { - name string - writeErr error - closeErr error - shortWrite bool -} - -func (f *fakeReportFile) Name() string { return f.name } -func (f *fakeReportFile) Write(content []byte) (int, error) { - if f.writeErr != nil { - return 0, f.writeErr - } - if f.shortWrite { - return len(content) - 1, nil - } - return len(content), nil -} -func (f *fakeReportFile) Close() error { return f.closeErr } - -func TestWriteReportFileAtomicFailures(t *testing.T) { - root := t.TempDir() - if err := writeReportFile(root, "reports/result.txt", []byte("ok")); err != nil { - t.Fatal(err) - } - if content, err := os.ReadFile(filepath.Join(root, "reports", "result.txt")); err != nil || string(content) != "ok" { - t.Fatalf("report=%q err=%v", content, err) - } - if err := writeReportFile(root, "../escape", nil); err == nil { - t.Fatal("report traversal accepted") - } - - oldMkdir, oldCreate := makeReportDirs, createReportTemp - oldRemove, oldRename := removeReportFile, renameReportFile - defer func() { - makeReportDirs, createReportTemp = oldMkdir, oldCreate - removeReportFile, renameReportFile = oldRemove, oldRename - }() - makeReportDirs = func(string, os.FileMode) error { return errors.New("mkdir") } - if err := writeReportFile(root, filepath.Join(root, "mkdir"), nil); err == nil { - t.Fatal("mkdir failure ignored") - } - makeReportDirs = oldMkdir - createReportTemp = func(string, string) (reportTempFile, error) { - return nil, errors.New("create") - } - if err := writeReportFile(root, filepath.Join(root, "create"), nil); err == nil { - t.Fatal("create failure ignored") - } - createReportTemp = func(string, string) (reportTempFile, error) { - return &fakeReportFile{name: filepath.Join(root, "write"), writeErr: errors.New("write")}, nil - } - if err := writeReportFile(root, filepath.Join(root, "write-target"), nil); err == nil { - t.Fatal("write failure ignored") - } - createReportTemp = func(string, string) (reportTempFile, error) { - return &fakeReportFile{name: filepath.Join(root, "short"), shortWrite: true}, nil - } - if err := writeReportFile(root, filepath.Join(root, "short-target"), []byte("content")); !errors.Is(err, io.ErrShortWrite) { - t.Fatalf("short write returned %v", err) - } - createReportTemp = func(string, string) (reportTempFile, error) { - return &fakeReportFile{name: filepath.Join(root, "close"), closeErr: errors.New("close")}, nil - } - if err := writeReportFile(root, filepath.Join(root, "close-target"), nil); err == nil { - t.Fatal("close failure ignored") - } - createReportTemp = func(string, string) (reportTempFile, error) { - return &fakeReportFile{name: filepath.Join(root, "rename")}, nil - } - renameReportFile = func(string, string) error { return errors.New("rename") } - if err := writeReportFile(root, filepath.Join(root, "rename-target"), nil); err == nil { - t.Fatal("rename failure ignored") - } -} - -type failingWriter struct{} - -func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("writer") } - -func TestVerifyOutputSecurityAndWriterFailure(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - oldGetwd := getwd - defer func() { getwd = oldGetwd }() - getwd = func() (string, error) { return root, nil } - - var stdout, stderr bytes.Buffer - if code := RunMain([]string{"verify", "--all", "--no-git", "--output", "../escape"}, &stdout, &stderr); code != exitcode.SecurityBoundary { - t.Fatalf("report escape returned %d: %s", code, stderr.String()) - } - cmd := newVerifyCmd() - cmd.SetArgs([]string{"--all", "--no-git"}) - cmd.SetOut(failingWriter{}) - cmd.SetErr(io.Discard) - if err := cmd.ExecuteContext(context.Background()); err == nil { - t.Fatal("report writer failure ignored") - } - - oldRunVerification := runVerification - defer func() { runVerification = oldRunVerification }() - runVerification = func(context.Context, verify.Options) (*verify.Outcome, error) { - return &verify.Outcome{ - Bundle: &evidence.Bundle{ - RunID: "failed", CreatedAt: time.Now().UTC(), - Run: verdict.RunResult{Verdict: verdict.Fail, Requirements: []verdict.RequirementResult{}}, - }, - ExitCode: exitcode.Fail, - }, nil - } - stdout.Reset() - stderr.Reset() - if code := RunMain([]string{"verify", "--all", "--no-git"}, &stdout, &stderr); code != exitcode.Fail { - t.Fatalf("failed verdict returned %d: %s", code, stderr.String()) - } -} - -func TestStatusAllVerdictsAndCompileFailure(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root, NoExample: true}); err != nil { - t.Fatal(err) - } - statuses := []string{ - verdict.Pass, verdict.Fail, verdict.Unproven, verdict.Uncertain, - verdict.ReviewRequired, verdict.Error, verdict.Skipped, "", - } - results := make([]verdict.RequirementResult, 0, len(statuses)-1) - for index, status := range statuses { - id := fmt.Sprintf("REQ-%03d", index+1) - requirement := fmt.Sprintf(`--- -id: %s -title: %s -status: active -priority: required ---- -# Intent -Intent. -# Obligations -`+"```yaml"+` -- id: OBL-001 - statement: Pass. - required: true - verify: - provider: command - run: "true" - result: {equals: 0} -`+"```"+` -`, id, id) - if err := os.WriteFile(filepath.Join(root, ".intentci", "requirements", id+".md"), []byte(requirement), 0o644); err != nil { - t.Fatal(err) - } - if status != "" { - results = append(results, verdict.RequirementResult{ - ID: id, Title: id, Priority: "required", Verdict: status, - Obligations: []verdict.ObligationResult{}, - }) - } - } - store, err := evidence.NewStore(root, ".intentci/runs") - if err != nil { - t.Fatal(err) - } - bundle := &evidence.Bundle{ - RunID: "status-run", CreatedAt: time.Now().UTC(), HeadCommit: "123456789", - RepositoryState: &repogit.State{ - BaseCommit: "987654321", WorkingTreeDirty: true, ChangedFiles: []string{"a", "b"}, - }, - Run: verdict.RunResult{Verdict: verdict.Error, Requirements: results}, - } - if err := store.WriteBundle(bundle); err != nil { - t.Fatal(err) - } - oldGetwd := getwd - defer func() { getwd = oldGetwd }() - getwd = func() (string, error) { return root, nil } - var stdout, stderr bytes.Buffer - if code := RunMain([]string{"status"}, &stdout, &stderr); code != exitcode.Pass { - t.Fatalf("status returned %d: %s", code, stderr.String()) - } - for _, expected := range []string{ - "Requirements: 8 active", "Verified: 1", "Failed: 1", - "Unproven: 2", "Uncertain: 1", "Review: 1", - "Errors: 1", "Skipped: 1", "Base:", "Dirty: true", - } { - if !strings.Contains(stdout.String(), expected) { - t.Fatalf("status missing %q:\n%s", expected, stdout.String()) - } - } - if err := os.WriteFile(filepath.Join(root, ".intentci", "requirements", "REQ-001.md"), []byte("bad"), 0o644); err != nil { - t.Fatal(err) - } - stdout.Reset() - stderr.Reset() - if code := RunMain([]string{"status"}, &stdout, &stderr); code != exitcode.CompileFailed { - t.Fatalf("invalid status contract returned %d: %s", code, stderr.String()) - } -} - -func TestSchemaAndExplainJSONIdentifiers(t *testing.T) { - for _, name := range []string{"report", "plan"} { - var stdout, stderr bytes.Buffer - if code := RunMain([]string{"schema", name}, &stdout, &stderr); code != exitcode.Pass || stdout.Len() == 0 { - t.Fatalf("schema %s returned %d: %s", name, code, stderr.String()) - } - } - record := provider.Evidence{ID: "EVIDENCE", VerifierID: "verifier"} - bundle := &evidence.Bundle{ - RunID: "run", - Run: verdict.RunResult{Requirements: []verdict.RequirementResult{{ - ID: "REQ", Obligations: []verdict.ObligationResult{{ - ID: "OBL", Evidence: []provider.Evidence{record}, - }}, - }}}, - ProviderLogs: map[string]provider.Result{ - "REQ/OBL/log": {Status: "completed"}, - "direct": {Status: "completed"}, - }, - } - for _, id := range []string{"run", "REQ", "OBL", "EVIDENCE", "verifier", "log", "direct"} { - if _, found := explainJSONValue(bundle, id); !found { - t.Fatalf("identifier %q not found", id) - } - } - if _, found := explainJSONValue(bundle, "missing"); found { - t.Fatal("missing identifier found") - } -} - -func TestRepairVerificationErrorReturnsInternal(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - initializeTestGit(t, root) - oldGetwd := getwd - oldRunVerification := runVerification - defer func() { - getwd = oldGetwd - runVerification = oldRunVerification - }() - getwd = func() (string, error) { return root, nil } - runVerification = func(context.Context, verify.Options) (*verify.Outcome, error) { - return &verify.Outcome{ExitCode: exitcode.Internal}, errors.New("verification") - } - var stdout, stderr bytes.Buffer - if code := RunMain([]string{"repair", "--changed", "--dry-run", "--max-attempts", "1"}, &stdout, &stderr); code != exitcode.Internal { - t.Fatalf("repair verification error returned %d: %s", code, stderr.String()) - } -} diff --git a/internal/cli/commands.go b/internal/cli/commands.go deleted file mode 100644 index 7fc5477..0000000 --- a/internal/cli/commands.go +++ /dev/null @@ -1,607 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - "github.com/spf13/cobra" - - "github.com/hypertrial/intentci/internal/compiler" - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/git" - "github.com/hypertrial/intentci/internal/initcmd" - "github.com/hypertrial/intentci/internal/repair" - "github.com/hypertrial/intentci/internal/report" - "github.com/hypertrial/intentci/internal/security" - "github.com/hypertrial/intentci/internal/verify" - appschema "github.com/hypertrial/intentci/pkg/schema" -) - -func newInitCmd() *cobra.Command { - var force, noExample bool - var language, ci string - cmd := &cobra.Command{ - Use: "init", - Short: "Initialize IntentCI in the current repository", - RunE: func(cmd *cobra.Command, args []string) error { - root, err := getwd() - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - if err := initcmd.Run(initcmd.Options{ - Root: root, Force: force, Language: language, - CIGithub: strings.EqualFold(ci, "github"), NoExample: noExample, - }); err != nil { - return exitErr(exitcode.Usage, "%v", err) - } - fmt.Fprintln(cmd.OutOrStdout(), "Initialized", config.Dir(root)) - return nil - }, - } - cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing config") - cmd.Flags().StringVar(&language, "language", "", "Example language (go|python|typescript|rust|java)") - cmd.Flags().StringVar(&ci, "ci", "", "CI template (github)") - cmd.Flags().BoolVar(&noExample, "no-example", false, "Skip example requirement") - return cmd -} - -func newCompileCmd() *cobra.Command { - var strict bool - var requirement, format, output string - cmd := &cobra.Command{ - Use: "compile", - Short: "Compile Markdown requirements into canonical Intent IR", - RunE: func(cmd *cobra.Command, args []string) error { - if format != "text" && format != "json" { - return exitErr(exitcode.Usage, "unsupported format %q", format) - } - if cmd.Flags().Changed("requirement") && strings.TrimSpace(requirement) == "" { - return exitErr(exitcode.Usage, "--requirement must not be empty") - } - root, err := getwd() - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - res, err := compiler.Compile(compiler.Options{ - Root: root, RequirementID: requirement, Strict: strict, - }) - for _, d := range res.Diagnostics { - fmt.Fprintln(cmd.ErrOrStderr(), d.Error()) - } - if err != nil { - return exitErr(exitcode.CompileFailed, "%v", err) - } - if requirement != "" && len(res.Document.Requirements) == 0 { - return exitErr(exitcode.Usage, "requirement %q not found", requirement) - } - store, err := evidence.NewStore(root, mustConfig(root).Evidence.Directory) - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - runID := evidence.NewRunID() - outPath := output - if outPath == "" { - outPath = filepath.Join(store.Dir(runID), "compiled-intent.json") - } - if err := compiler.WriteIR(res.Document, outPath); err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - _ = os.WriteFile(filepath.Join(store.Root, "latest-compile"), []byte(runID+"\n"), 0o644) - if format == "json" { - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - return enc.Encode(res.Document) - } - fmt.Fprintf(cmd.OutOrStdout(), "Compiled %d requirement(s) → %s\n", len(res.Document.Requirements), outPath) - return nil - }, - } - cmd.Flags().BoolVar(&strict, "strict", false, "Fail on any diagnostic") - cmd.Flags().StringVar(&requirement, "requirement", "", "Compile a single requirement id") - cmd.Flags().StringVar(&format, "format", "text", "text|json") - cmd.Flags().StringVar(&output, "output", "", "Output path for IR JSON") - return cmd -} - -func mustConfig(root string) *config.Config { - cfg, err := config.Load(root) - if err != nil { - return config.Default() - } - return cfg -} - -func newVerifyCmd() *cobra.Command { - var all, changed, noCache, failFast, noGit bool - var base, head, requirement, obligation, providerID, format, output string - var maxParallel int - cmd := &cobra.Command{ - Use: "verify", - Short: "Compile, select, execute providers, and emit verdicts", - RunE: func(cmd *cobra.Command, args []string) error { - root, err := getwd() - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - if all && changed { - return exitErr(exitcode.Usage, "--all and --changed are mutually exclusive") - } - if format != "text" && format != "json" && format != "junit" { - return exitErr(exitcode.Usage, "unsupported format %q", format) - } - if maxParallel < 0 { - return exitErr(exitcode.Usage, "--max-parallel must be >= 0") - } - for name, value := range map[string]string{ - "requirement": requirement, "obligation": obligation, "provider": providerID, - } { - if cmd.Flags().Changed(name) && strings.TrimSpace(value) == "" { - return exitErr(exitcode.Usage, "--%s must not be empty", name) - } - } - if !all && !changed && requirement == "" { - changed = true - } - out, err := runVerification(cmd.Context(), verify.Options{ - Root: root, Base: base, Head: head, All: all, Changed: changed, - RequirementID: requirement, ObligationID: obligation, - ProviderID: providerID, MaxParallel: maxParallel, - MaxParallelSet: cmd.Flags().Changed("max-parallel"), - FailFast: failFast, FailFastSet: cmd.Flags().Changed("fail-fast"), - NoGit: noGit, NoCache: noCache, Format: format, - }) - if err != nil { - code := exitcode.Internal - if out != nil && out.ExitCode != 0 { - code = out.ExitCode - } - return exitErr(code, "%v", err) - } - _ = report.WriteGitHubStepSummary(out.Bundle) - w := cmd.OutOrStdout() - if output != "" { - var rendered bytes.Buffer - _ = report.Write(&rendered, format, out.Bundle) - if err := writeReportFile(root, output, rendered.Bytes()); err != nil { - if security.IsPathViolation(err) { - return exitErr(exitcode.SecurityBoundary, "%v", err) - } - return exitErr(exitcode.Internal, "%v", err) - } - } else if err := report.Write(w, format, out.Bundle); err != nil { - return exitErr(exitcode.Usage, "%v", err) - } - if out.ExitCode != exitcode.Pass { - return &ExitError{Code: out.ExitCode} - } - return nil - }, - } - cmd.Flags().BoolVar(&all, "all", false, "Verify all active requirements") - cmd.Flags().BoolVar(&changed, "changed", false, "Verify requirements affected by the Git diff") - cmd.Flags().StringVar(&base, "base", "", "Git base ref") - cmd.Flags().StringVar(&head, "head", "", "Git head ref") - cmd.Flags().StringVar(&requirement, "requirement", "", "Requirement id") - cmd.Flags().StringVar(&obligation, "obligation", "", "Obligation id") - cmd.Flags().StringVar(&providerID, "provider", "", "Verifier provider id") - cmd.Flags().IntVar(&maxParallel, "max-parallel", 0, "Override verification.max_parallel") - cmd.Flags().BoolVar(&failFast, "fail-fast", false, "Stop scheduling after the first non-pass result") - cmd.Flags().BoolVar(&noCache, "no-cache", false, "Disable provider cache") - cmd.Flags().BoolVar(&noGit, "no-git", false, "Verify without Git provenance (requires --all or --requirement)") - cmd.Flags().StringVar(&format, "format", "text", "text|json|junit") - cmd.Flags().StringVar(&output, "output", "", "Write report to path") - return cmd -} - -func writeReportFile(root, relative string, content []byte) error { - path := relative - if !filepath.IsAbs(path) { - var err error - path, err = security.ResolveInside(root, relative) - if err != nil { - return err - } - } - if err := makeReportDirs(filepath.Dir(path), 0o755); err != nil { - return err - } - file, err := createReportTemp(filepath.Dir(path), ".intentci-report-*") - if err != nil { - return err - } - name := file.Name() - defer removeReportFile(name) - written, err := file.Write(content) - if err != nil { - file.Close() - return err - } - if written != len(content) { - file.Close() - return io.ErrShortWrite - } - if err := file.Close(); err != nil { - return err - } - return renameReportFile(name, path) -} - -type reportTempFile interface { - io.Writer - Name() string - Close() error -} - -var makeReportDirs = os.MkdirAll -var createReportTemp = func(directory, pattern string) (reportTempFile, error) { - return os.CreateTemp(directory, pattern) -} -var removeReportFile = os.Remove -var renameReportFile = os.Rename -var runVerification = verify.Run - -func newExplainCmd() *cobra.Command { - var runID string - var showEvidence, showLogs bool - var format string - cmd := &cobra.Command{ - Use: "explain [id]", - Short: "Explain a requirement verdict from a run", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - root, err := getwd() - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - cfg := mustConfig(root) - if format != "text" && format != "json" { - return exitErr(exitcode.Usage, "unsupported format %q", format) - } - store, err := evidence.NewStore(root, cfg.Evidence.Directory) - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - var b *evidence.Bundle - if runID != "" { - b, err = store.Load(runID) - } else { - b, err = store.LoadLatest() - } - if err != nil { - return exitErr(exitcode.Usage, "no run to explain: %v", err) - } - if format == "json" { - value, found := explainJSONValue(b, args[0]) - if !found { - return exitErr(exitcode.Usage, "identifier %q not found", args[0]) - } - if showLogs { - value = map[string]any{"result": value, "provider_results": b.ProviderLogs} - } - enc := json.NewEncoder(cmd.OutOrStdout()) - enc.SetIndent("", " ") - return enc.Encode(value) - } - if err := report.ExplainWithOptions(cmd.OutOrStdout(), b, args[0], report.ExplainOptions{ - ShowEvidence: showEvidence, ShowLogs: showLogs, - }); err != nil { - return exitErr(exitcode.Usage, "%v", err) - } - return nil - }, - } - cmd.Flags().StringVar(&runID, "run", "", "Run id") - cmd.Flags().BoolVar(&showEvidence, "show-evidence", false, "Show evidence details") - cmd.Flags().BoolVar(&showLogs, "show-logs", false, "Show provider logs") - cmd.Flags().StringVar(&format, "format", "text", "text|json") - return cmd -} - -func newRepairCmd() *cobra.Command { - var agent, agentCommand, requirement string - var maxAttempts int - var dryRun, changed, allowTest bool - cmd := &cobra.Command{ - Use: "repair", - Short: "Run a bounded agent repair loop", - RunE: func(cmd *cobra.Command, args []string) error { - if agent != "" && agentCommand != "" { - return exitErr(exitcode.Usage, "--agent and --agent-command are mutually exclusive") - } - if maxAttempts < 0 || (cmd.Flags().Changed("max-attempts") && maxAttempts < 1) { - return exitErr(exitcode.Usage, "--max-attempts must be >= 1") - } - if cmd.Flags().Changed("requirement") && strings.TrimSpace(requirement) == "" { - return exitErr(exitcode.Usage, "--requirement must not be empty") - } - if cmd.Flags().Changed("agent") && strings.TrimSpace(agent) == "" { - return exitErr(exitcode.Usage, "--agent must not be empty") - } - if cmd.Flags().Changed("agent-command") && strings.TrimSpace(agentCommand) == "" { - return exitErr(exitcode.Usage, "--agent-command must not be empty") - } - if agent != "" { - if !validAdapterName(agent) { - return exitErr(exitcode.Usage, "invalid agent name %q", agent) - } - path, err := exec.LookPath("intentci-agent-" + agent) - if err != nil { - return exitErr(exitcode.Usage, "agent %q not found on PATH", agent) - } - agentCommand = fmt.Sprintf("%q {packet}", path) - } - root, err := getwd() - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - cfg, err := config.Load(root) - if err != nil { - return exitErr(exitcode.CompileFailed, "%v", err) - } - if allowTest { - cfg.Repair.AllowTestChanges = true - } - compiled, err := compiler.Compile(compiler.Options{Root: root, Config: cfg, Strict: true}) - if err != nil { - return exitErr(exitcode.CompileFailed, "%v", err) - } - store, err := evidence.NewStore(root, cfg.Evidence.Directory) - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - store.RedactPatterns = append([]string{}, cfg.Evidence.Redact.Environment...) - runID := evidence.NewRunID() - attempt := 0 - if agentCommand != "" && !dryRun { - fmt.Fprintln(cmd.ErrOrStderr(), "WARNING: repair executes the agent on the host with your current user permissions; IntentCI v1 is not a sandbox.") - } - out, err := repair.Run(cmd.Context(), repair.Options{ - Root: root, Config: cfg, Store: store, AgentCommand: agentCommand, - MaxAttempts: maxAttempts, DryRun: dryRun, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { - attempt++ - o, err := runVerification(ctx, verify.Options{ - Root: root, All: !changed, Changed: changed, RequirementID: requirement, NoCache: true, - Config: cfg, Document: compiled.Document, RunID: runID, - AttemptID: fmt.Sprintf("attempt-%03d", attempt), AttemptOnly: true, - }) - if err != nil { - return nil, err - } - return o.Bundle, nil - }, - Finalize: func(bundle *evidence.Bundle) error { - return verify.FinalizeBundle(store, bundle) - }, - }) - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - if out.Bundle != nil { - _ = report.Write(cmd.OutOrStdout(), "text", out.Bundle) - } - if out.Stopped != "" { - fmt.Fprintln(cmd.ErrOrStderr(), "repair stopped:", out.Stopped) - } - if out.ExitCode != exitcode.Pass { - return &ExitError{Code: out.ExitCode} - } - return nil - }, - } - cmd.Flags().StringVar(&agent, "agent", "", "Agent name resolved as intentci-agent-NAME on PATH") - cmd.Flags().StringVar(&agentCommand, "agent-command", "", "Shell command; {packet} is replaced with packet path") - cmd.Flags().StringVar(&requirement, "requirement", "", "Limit to requirement id") - cmd.Flags().IntVar(&maxAttempts, "max-attempts", 0, "Override repair.max_attempts") - cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Build packets without invoking an agent") - cmd.Flags().BoolVar(&changed, "changed", false, "Verify changed requirements each attempt") - cmd.Flags().BoolVar(&allowTest, "allow-test-changes", false, "Allow the agent to modify tests") - return cmd -} - -func validAdapterName(value string) bool { - if value == "" { - return false - } - for _, character := range value { - if (character < 'a' || character > 'z') && - (character < '0' || character > '9') && character != '-' { - return false - } - } - return true -} - -func newStatusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Show repository IntentCI status from the latest run", - RunE: func(cmd *cobra.Command, args []string) error { - root, err := getwd() - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - cfg := mustConfig(root) - store, err := evidence.NewStore(root, cfg.Evidence.Directory) - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - b, err := store.LoadLatest() - if err != nil { - return exitErr(exitcode.Usage, "no runs yet: %v", err) - } - compiled, err := compiler.Compile(compiler.Options{Root: root, Config: cfg}) - if err != nil { - return exitErr(exitcode.CompileFailed, "%v", err) - } - latest := map[string]string{} - for _, result := range b.Run.Requirements { - latest[result.ID] = result.Verdict - } - active, verified, failed, unproven, uncertain := 0, 0, 0, 0, 0 - review, errors, skipped := 0, 0, 0 - for _, requirement := range compiled.Document.ActiveRequirements() { - active++ - switch latest[requirement.ID] { - case "pass": - verified++ - case "fail": - failed++ - case "unproven": - unproven++ - case "uncertain": - uncertain++ - case "review_required": - review++ - case "error": - errors++ - case "skipped": - skipped++ - default: - unproven++ - } - } - fmt.Fprintf(cmd.OutOrStdout(), "Requirements: %d active\nVerified: %d\nFailed: %d\nUnproven: %d\nUncertain: %d\nReview: %d\nErrors: %d\nSkipped: %d\nLast run: %s\nCommit: %s\n", - active, verified, failed, unproven, uncertain, review, errors, skipped, - b.CreatedAt.Format(timeRFC3339), short(b.HeadCommit)) - if b.RepositoryState != nil { - fmt.Fprintf(cmd.OutOrStdout(), "Base: %s\nDirty: %t\nChanged files: %d\n", - short(b.RepositoryState.BaseCommit), b.RepositoryState.WorkingTreeDirty, - len(b.RepositoryState.ChangedFiles)) - } - return nil - }, - } -} - -const timeRFC3339 = "2006-01-02T15:04:05Z" - -func short(s string) string { - if len(s) > 7 { - return s[:7] - } - return s -} - -func newDoctorCmd() *cobra.Command { - return &cobra.Command{ - Use: "doctor", - Short: "Check local dependencies and configuration", - RunE: func(cmd *cobra.Command, args []string) error { - root, err := getwd() - if err != nil { - return exitErr(exitcode.Internal, "%v", err) - } - ok := true - check := func(name string, err error) { - if err != nil { - ok = false - fmt.Fprintf(cmd.OutOrStdout(), "FAIL %s: %v\n", name, err) - return - } - fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", name) - } - if !git.IsRepo(root) { - check("git repository", fmt.Errorf("not a git repository")) - } else { - check("git repository", nil) - } - cfg, err := config.Load(root) - check("configuration", err) - if err == nil { - store, err := evidence.NewStore(root, cfg.Evidence.Directory) - if err != nil { - check("evidence directory", err) - } else { - probe := filepath.Join(store.Root, ".write-probe") - err := os.WriteFile(probe, []byte("ok"), 0o644) - _ = os.Remove(probe) - check("evidence directory writable", err) - } - } - switch goos { - case "linux", "darwin": - check("platform "+goos+"/"+runtime.GOARCH, nil) - default: - check("platform", fmt.Errorf("unsupported %s (use WSL on Windows)", goos)) - } - if cfg != nil && cfg.Telemetry.Enabled { - check("telemetry disabled", fmt.Errorf("telemetry.enabled is true")) - } else { - check("telemetry disabled", nil) - } - if !ok { - return &ExitError{Code: exitcode.Usage} - } - return nil - }, - } -} - -func newSchemaCmd() *cobra.Command { - return &cobra.Command{ - Use: "schema [name]", - Short: "Print a JSON schema (requirement|evidence|verdict|repair|ir|plan|report)", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var raw []byte - switch strings.ToLower(args[0]) { - case "requirement": - raw = appschema.RequirementJSON - case "evidence": - raw = appschema.EvidenceJSON - case "verdict": - raw = appschema.VerdictJSON - case "repair": - raw = appschema.RepairJSON - case "ir", "intent": - raw = appschema.IRJSON - case "report": - raw = appschema.ReportJSON - case "plan": - raw = appschema.PlanJSON - default: - return exitErr(exitcode.Usage, "unknown schema %q", args[0]) - } - _, err := cmd.OutOrStdout().Write(raw) - return err - }, - } -} - -func explainJSONValue(bundle *evidence.Bundle, id string) (any, bool) { - if id == bundle.RunID { - return bundle, true - } - for _, requirement := range bundle.Run.Requirements { - if requirement.ID == id { - return requirement, true - } - for _, obligation := range requirement.Obligations { - if obligation.ID == id { - return obligation, true - } - for _, record := range obligation.Evidence { - if record.ID == id || record.VerifierID == id { - return record, true - } - } - } - } - for key, result := range bundle.ProviderLogs { - if key == id || strings.HasSuffix(key, "/"+id) { - return result, true - } - } - return nil, false -} diff --git a/internal/cli/root.go b/internal/cli/root.go deleted file mode 100644 index 576a77a..0000000 --- a/internal/cli/root.go +++ /dev/null @@ -1,91 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "io" - "os" - "os/signal" - "runtime" - "syscall" - - "github.com/spf13/cobra" - - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/version" -) - -// ExecuteWith runs the root command. -func ExecuteWith(args []string, stdout, stderr io.Writer) error { - root := newRoot() - root.SetArgs(args) - root.SetOut(stdout) - root.SetErr(stderr) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - return root.ExecuteContext(ctx) -} - -func newRoot() *cobra.Command { - root := &cobra.Command{ - Use: "intentci", - Short: "Intent compiler and evidence-based verification for agent-generated code", - Long: "IntentCI compiles Markdown requirements into obligations, runs providers, and produces evidence-backed verdicts.", - SilenceErrors: true, - SilenceUsage: true, - } - root.AddCommand(newInitCmd()) - root.AddCommand(newCompileCmd()) - root.AddCommand(newVerifyCmd()) - root.AddCommand(newExplainCmd()) - root.AddCommand(newRepairCmd()) - root.AddCommand(newStatusCmd()) - root.AddCommand(newDoctorCmd()) - root.AddCommand(newSchemaCmd()) - root.AddCommand(&cobra.Command{ - Use: "version", - Short: "Print IntentCI version", - Run: func(cmd *cobra.Command, args []string) { - fmt.Fprintln(cmd.OutOrStdout(), version.String()) - }, - }) - return root -} - -// RunMain executes IntentCI and returns an exit code. -func RunMain(args []string, stdout, stderr io.Writer) int { - err := ExecuteWith(args, stdout, stderr) - if err == nil { - return exitcode.Pass - } - if ee, ok := err.(*ExitError); ok { - if ee.Msg != "" { - fmt.Fprintln(stderr, ee.Msg) - } - return ee.Code - } - fmt.Fprintln(stderr, err.Error()) - return exitcode.Internal -} - -// ExitError carries a process exit code. -type ExitError struct { - Code int - Msg string -} - -func (e *ExitError) Error() string { - if e.Msg != "" { - return e.Msg - } - return fmt.Sprintf("exit %d", e.Code) -} - -func exitErr(code int, format string, args ...any) error { - return &ExitError{Code: code, Msg: fmt.Sprintf(format, args...)} -} - -var getwd = os.Getwd - -// goos is overridable in tests for doctor platform checks. -var goos = runtime.GOOS diff --git a/internal/compiler/benchmark_test.go b/internal/compiler/benchmark_test.go deleted file mode 100644 index 1861fee..0000000 --- a/internal/compiler/benchmark_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package compiler_test - -import ( - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/compiler" -) - -func BenchmarkV1Compile100Requirements(b *testing.B) { - benchmarkCompilation(b, 100) -} - -func BenchmarkV1Compile1000Requirements(b *testing.B) { - benchmarkCompilation(b, 1000) -} - -func benchmarkCompilation(b *testing.B, count int) { - root := b.TempDir() - requirements := filepath.Join(root, ".intentci", "requirements") - if err := os.MkdirAll(requirements, 0o755); err != nil { - b.Fatal(err) - } - configBody := `version: 1 -project: - name: benchmark -requirements: - paths: - - .intentci/requirements/**/*.md -` - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), []byte(configBody), 0o644); err != nil { - b.Fatal(err) - } - for index := 0; index < count; index++ { - id := fmt.Sprintf("REQ-%04d", index) - requirement := fmt.Sprintf(`--- -id: %s -title: Benchmark requirement %d -status: active -priority: required -owners: [benchmark] -applies_to: - paths: ["src/%04d/**"] ---- -# Intent -Keep benchmark behavior correct. -# Obligations -`+"```yaml"+` -- id: OBL-001 - statement: Benchmark evidence exists. - required: true - verify: - provider: command - id: check - run: "printf 'intentci-ok\n'" - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -`+"```"+` -`, id, index, index) - if err := os.WriteFile(filepath.Join(requirements, id+".md"), []byte(requirement), 0o644); err != nil { - b.Fatal(err) - } - } - b.ResetTimer() - for iteration := 0; iteration < b.N; iteration++ { - if _, err := compiler.Compile(compiler.Options{Root: root, Strict: true}); err != nil { - b.Fatal(err) - } - } -} diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go deleted file mode 100644 index cf46e5e..0000000 --- a/internal/compiler/compiler.go +++ /dev/null @@ -1,755 +0,0 @@ -package compiler - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/bmatcuk/doublestar/v4" - "github.com/oklog/ulid/v2" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/parser" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/security" - appschema "github.com/hypertrial/intentci/pkg/schema" -) - -// Result is a compile outcome. -type Result struct { - Document *ir.Document - Diagnostics []parser.Diagnostic -} - -// Options configures compilation. -type Options struct { - Root string - Config *config.Config - RequirementID string - Strict bool -} - -var supportedProviders = map[string]bool{ - "command": true, - "junit": true, - "sarif": true, - "boundary": true, - "git-diff": true, - "json": true, - "manual": true, -} - -var lookPath = exec.LookPath - -var absPath = filepath.Abs -var mkdirAll = os.MkdirAll -var writeFile = os.WriteFile -var renameFile = os.Rename -var removeFile = os.Remove -var computeHashes = func(doc *ir.Document) error { return doc.ComputeHashes() } - -// Compile parses requirements and produces canonical IR. -func Compile(opt Options) (*Result, error) { - if opt.Config == nil { - cfg, err := config.Load(opt.Root) - if err != nil { - return &Result{Diagnostics: []parser.Diagnostic{{Message: err.Error()}}}, err - } - opt.Config = cfg - } - files, err := discover(opt.Root, opt.Config.Requirements.Paths) - if err != nil { - return &Result{Diagnostics: []parser.Diagnostic{{Message: err.Error()}}}, err - } - sort.Strings(files) - - var diags []parser.Diagnostic - workingDirectory, workingErr := security.ResolveInside(opt.Root, opt.Config.Verification.WorkingDirectory) - workingInfo, statErr := os.Stat(workingDirectory) - if workingErr != nil || statErr != nil || !workingInfo.IsDir() { - diags = append(diags, parser.Diagnostic{ - Message: "verification.working_directory does not exist or is unsafe", - }) - } - reqs := make([]ir.Requirement, 0) - seen := map[string]string{} - for _, f := range files { - rel, _ := filepath.Rel(opt.Root, f) - req, d := parser.ParseFile(f) - if rel != "" { - req.SourcePath = filepath.ToSlash(rel) - } - diags = append(diags, d...) - if opt.RequirementID != "" && req.ID != opt.RequirementID { - continue - } - if req.ID != "" { - if prev, ok := seen[req.ID]; ok { - diags = append(diags, parser.Diagnostic{ - Path: req.SourcePath, - Message: fmt.Sprintf("duplicate requirement id %q (also in %s)", req.ID, prev), - }) - } else { - seen[req.ID] = req.SourcePath - } - reqs = append(reqs, req) - } - } - - doc := &ir.Document{ - SchemaVersion: ir.SchemaVersion, - Project: opt.Config.Project.Name, - Requirements: reqs, - } - diags = append(diags, validateGraph(doc)...) - diags = append(diags, validateRequirements(doc)...) - diags = append(diags, validateProviders(opt.Root, doc)...) - diags = append(diags, validateBoundaries(doc)...) - diags = append(diags, compilerWarnings(doc, opt.Config)...) - - if err := computeHashes(doc); err != nil { - return &Result{Document: doc, Diagnostics: diags}, err - } - if err := appschema.Validate("ir", doc); err != nil { - diags = append(diags, parser.Diagnostic{Message: err.Error()}) - } - - res := &Result{Document: doc, Diagnostics: diags} - if opt.Strict && len(diags) > 0 { - return res, fmt.Errorf("compile failed with %d diagnostic(s)", len(diags)) - } - if hasErrors(diags) { - return res, fmt.Errorf("compile failed with %d diagnostic(s)", len(diags)) - } - return res, nil -} - -func hasErrors(d []parser.Diagnostic) bool { - for _, diag := range d { - if diag.Severity != parser.SeverityWarning { - return true - } - } - return false -} - -func discover(root string, patterns []string) ([]string, error) { - var out []string - seen := map[string]bool{} - for _, pat := range patterns { - p := pat - if !filepath.IsAbs(p) { - p = filepath.Join(root, p) - } - matches, err := doublestar.FilepathGlob(p) - if err != nil { - return nil, err - } - for _, m := range matches { - info, err := os.Stat(m) - if err != nil || info.IsDir() { - continue - } - if !strings.HasSuffix(strings.ToLower(m), ".md") { - continue - } - abs, err := absPath(m) - if err != nil { - continue - } - if !seen[abs] { - seen[abs] = true - out = append(out, abs) - } - } - } - return out, nil -} - -func validateGraph(doc *ir.Document) []parser.Diagnostic { - var diags []parser.Diagnostic - ids := map[string]bool{} - for _, r := range doc.Requirements { - ids[r.ID] = true - } - for _, r := range doc.Requirements { - for _, dep := range r.DependsOn { - if !ids[dep] { - diags = append(diags, parser.Diagnostic{ - Path: r.SourcePath, - Message: fmt.Sprintf("depends_on unknown requirement %q", dep), - }) - } - } - oblIDs := map[string]bool{} - for _, o := range r.Obligations { - if oblIDs[o.ID] { - diags = append(diags, parser.Diagnostic{ - Path: r.SourcePath, - Message: fmt.Sprintf("duplicate obligation id %q", o.ID), - }) - } - oblIDs[o.ID] = true - } - for _, o := range r.Obligations { - for _, dep := range o.DependsOn { - if !oblIDs[dep] { - diags = append(diags, parser.Diagnostic{ - Path: r.SourcePath, Message: fmt.Sprintf("%s depends_on unknown obligation %q", o.ID, dep), - }) - } - } - } - if cycle := obligationCycle(r.Obligations); cycle != "" { - diags = append(diags, parser.Diagnostic{ - Path: r.SourcePath, Message: "cyclic obligation dependency involving " + cycle, - }) - } - } - // cycles - visiting := map[string]bool{} - visited := map[string]bool{} - var visit func(id string) bool - visit = func(id string) bool { - if visiting[id] { - return true - } - if visited[id] { - return false - } - visiting[id] = true - r := doc.RequirementByID(id) - if r != nil { - for _, dep := range r.DependsOn { - if visit(dep) { - return true - } - } - } - visiting[id] = false - visited[id] = true - return false - } - for _, r := range doc.Requirements { - if visit(r.ID) { - diags = append(diags, parser.Diagnostic{ - Path: r.SourcePath, - Message: "cyclic requirement dependency involving " + r.ID, - }) - break - } - } - return diags -} - -func obligationCycle(obligations []ir.Obligation) string { - byID := make(map[string]ir.Obligation, len(obligations)) - for _, obligation := range obligations { - byID[obligation.ID] = obligation - } - visiting := map[string]bool{} - visited := map[string]bool{} - var visit func(string) bool - visit = func(id string) bool { - if visiting[id] { - return true - } - if visited[id] { - return false - } - visiting[id] = true - for _, dep := range byID[id].DependsOn { - if _, ok := byID[dep]; ok && visit(dep) { - return true - } - } - visiting[id] = false - visited[id] = true - return false - } - for id := range byID { - if visit(id) { - return id - } - } - return "" -} - -func validateRequirements(doc *ir.Document) []parser.Diagnostic { - var diags []parser.Diagnostic - for _, requirement := range doc.Requirements { - for _, pattern := range append(append([]string{}, requirement.AppliesTo.Paths...), append(requirement.Boundaries.Allowed, requirement.Boundaries.Forbidden...)...) { - if !validPattern(pattern) { - diags = append(diags, parser.Diagnostic{Path: requirement.SourcePath, Message: fmt.Sprintf("invalid path pattern %q", pattern)}) - } - } - if requirement.Timeout != "" { - if _, err := config.ParseDuration(requirement.Timeout); err != nil { - diags = append(diags, parser.Diagnostic{Path: requirement.SourcePath, Message: "timeout: " + err.Error()}) - } - } - for _, obligation := range requirement.Obligations { - if !oneOf(obligation.EvidenceClass, "", "deterministic", "probabilistic", "human", "informational") { - diags = append(diags, parser.Diagnostic{Path: requirement.SourcePath, Message: obligation.ID + ": invalid evidence_class"}) - } - if !oneOf(obligation.Severity, "", "error", "warning", "note") { - diags = append(diags, parser.Diagnostic{Path: requirement.SourcePath, Message: obligation.ID + ": invalid severity"}) - } - if obligation.ConfidenceThreshold != nil && (*obligation.ConfidenceThreshold < 0 || *obligation.ConfidenceThreshold > 1) { - diags = append(diags, parser.Diagnostic{Path: requirement.SourcePath, Message: obligation.ID + ": confidence_threshold must be between 0 and 1"}) - } - if obligation.ConfidenceThreshold != nil && obligation.EvidenceClass != "probabilistic" { - diags = append(diags, parser.Diagnostic{Path: requirement.SourcePath, Message: obligation.ID + ": confidence_threshold requires probabilistic evidence"}) - } - if obligation.Timeout != "" { - if _, err := config.ParseDuration(obligation.Timeout); err != nil { - diags = append(diags, parser.Diagnostic{Path: requirement.SourcePath, Message: obligation.ID + ": timeout: " + err.Error()}) - } - } - diags = append(diags, validateRetry(requirement.SourcePath, obligation.ID, obligation.Retry)...) - for _, platform := range obligation.Platforms { - if !oneOf(platform, "linux", "darwin") { - diags = append(diags, parser.Diagnostic{Path: requirement.SourcePath, Message: fmt.Sprintf("%s: unsupported platform %q", obligation.ID, platform)}) - } - } - } - } - return diags -} - -func validateProviders(root string, doc *ir.Document) []parser.Diagnostic { - var diags []parser.Diagnostic - registry := provider.DefaultRegistry() - var walk func(path, obl string, n ir.VerifyNode) - walk = func(path, obl string, n ir.VerifyNode) { - if n.Provider != nil { - p := n.Provider.Provider - if !validLocalID(p) { - diags = append(diags, parser.Diagnostic{ - Path: path, Message: fmt.Sprintf("%s: invalid provider name %q", obl, p), - }) - } - if n.Provider.ID != "" && !validLocalID(n.Provider.ID) { - diags = append(diags, parser.Diagnostic{ - Path: path, Message: fmt.Sprintf("%s: invalid verifier id %q", obl, n.Provider.ID), - }) - } - if !supportedProviders[p] { - if _, err := lookPath("intentci-provider-" + p); err != nil { - diags = append(diags, parser.Diagnostic{ - Path: path, Message: fmt.Sprintf("%s: unsupported provider %q", obl, p), - }) - } - } else if implementation, ok := registry.Get(p); ok { - for _, diagnostic := range implementation.Validate(*n.Provider) { - diags = append(diags, parser.Diagnostic{Path: path, Message: obl + ": " + diagnostic.Message}) - } - } - for _, pattern := range providerPaths(*n.Provider) { - if !validPattern(pattern) { - diags = append(diags, parser.Diagnostic{Path: path, Message: fmt.Sprintf("%s: invalid provider path %q", obl, pattern)}) - } - } - if n.Provider.WorkingDirectory != "" && !validRelativePath(n.Provider.WorkingDirectory) { - diags = append(diags, parser.Diagnostic{Path: path, Message: obl + ": invalid working_directory"}) - } else if n.Provider.WorkingDirectory != "" { - resolved, err := security.ResolveInside(root, n.Provider.WorkingDirectory) - info, statErr := os.Stat(resolved) - if err != nil || statErr != nil || !info.IsDir() { - diags = append(diags, parser.Diagnostic{Path: path, Message: obl + ": working_directory does not exist or is unsafe"}) - } - } - if n.Provider.Report != "" && !validRelativePath(n.Provider.Report) { - diags = append(diags, parser.Diagnostic{Path: path, Message: obl + ": invalid report path"}) - } else if n.Provider.Report != "" && n.Provider.Run == "" { - resolved, err := security.ResolveInside(root, n.Provider.Report) - info, statErr := os.Stat(resolved) - if err != nil || statErr != nil || !info.Mode().IsRegular() { - diags = append(diags, parser.Diagnostic{Path: path, Message: obl + ": referenced report does not exist or is unsafe"}) - } - } - for _, pattern := range n.Provider.InheritEnv { - if _, err := filepath.Match(pattern, "INTENTCI"); err != nil { - diags = append(diags, parser.Diagnostic{ - Path: path, Message: fmt.Sprintf("%s: invalid inherited environment pattern %q", obl, pattern), - }) - } - } - for name := range n.Provider.Environment { - if name == "" || strings.ContainsAny(name, "=\x00") { - diags = append(diags, parser.Diagnostic{ - Path: path, Message: fmt.Sprintf("%s: invalid environment name %q", obl, name), - }) - } - } - if n.Provider.Timeout != "" { - if _, err := config.ParseDuration(n.Provider.Timeout); err != nil { - diags = append(diags, parser.Diagnostic{Path: path, Message: obl + ": timeout: " + err.Error()}) - } - } - diags = append(diags, validateRetry(path, obl, n.Provider.Retry)...) - } - for _, c := range n.All { - walk(path, obl, c) - } - for _, c := range n.Any { - walk(path, obl, c) - } - if n.Not != nil { - walk(path, obl, *n.Not) - } - } - for _, r := range doc.Requirements { - for _, o := range r.Obligations { - walk(r.SourcePath, o.ID, o.Verify) - seen := map[string]bool{} - collectProviderIDs(o.Verify, func(id string) { - if id != "" && seen[id] { - diags = append(diags, parser.Diagnostic{Path: r.SourcePath, Message: fmt.Sprintf("%s: duplicate verifier id %q", o.ID, id)}) - } - seen[id] = true - }) - } - diags = append(diags, validateVerifierGraph(r)...) - } - return diags -} - -func validLocalID(value string) bool { - if value == "" { - return false - } - for _, character := range value { - if (character < 'a' || character > 'z') && - (character < 'A' || character > 'Z') && - (character < '0' || character > '9') && - character != '-' && character != '_' && character != '.' { - return false - } - } - return true -} - -func validateVerifierGraph(requirement ir.Requirement) []parser.Diagnostic { - var diagnostics []parser.Diagnostic - specs := map[string]ir.ProviderSpec{} - dependencies := map[string][]string{} - for _, obligation := range requirement.Obligations { - var walk func(ir.VerifyNode) - walk = func(node ir.VerifyNode) { - if node.Provider != nil && node.Provider.ID != "" { - if prior, ok := specs[node.Provider.ID]; ok { - left, _ := ir.CanonicalJSON(prior) - right, _ := ir.CanonicalJSON(*node.Provider) - if string(left) != string(right) { - diagnostics = append(diagnostics, parser.Diagnostic{ - Path: requirement.SourcePath, - Message: fmt.Sprintf("verifier id %q is reused with incompatible configuration", node.Provider.ID), - }) - } - } else { - specs[node.Provider.ID] = *node.Provider - dependencies[node.Provider.ID] = append([]string{}, node.Provider.DependsOn...) - } - } - for _, child := range node.All { - walk(child) - } - for _, child := range node.Any { - walk(child) - } - if node.Not != nil { - walk(*node.Not) - } - } - walk(obligation.Verify) - } - for id, values := range dependencies { - for _, dependency := range values { - if _, ok := specs[dependency]; !ok { - diagnostics = append(diagnostics, parser.Diagnostic{ - Path: requirement.SourcePath, - Message: fmt.Sprintf("verifier %q depends_on unknown verifier %q", id, dependency), - }) - } - } - } - visiting := map[string]bool{} - visited := map[string]bool{} - var visit func(string) bool - visit = func(id string) bool { - if visiting[id] { - return true - } - if visited[id] { - return false - } - visiting[id] = true - for _, dependency := range dependencies[id] { - if visit(dependency) { - return true - } - } - visiting[id] = false - visited[id] = true - return false - } - for id := range dependencies { - if visit(id) { - diagnostics = append(diagnostics, parser.Diagnostic{ - Path: requirement.SourcePath, Message: "cyclic verifier dependency involving " + id, - }) - break - } - } - return diagnostics -} - -func collectProviderIDs(node ir.VerifyNode, fn func(string)) { - if node.Provider != nil { - fn(node.Provider.ID) - } - for _, child := range node.All { - collectProviderIDs(child, fn) - } - for _, child := range node.Any { - collectProviderIDs(child, fn) - } - if node.Not != nil { - collectProviderIDs(*node.Not, fn) - } -} - -func providerPaths(spec ir.ProviderSpec) []string { - var paths []string - paths = append(paths, spec.Allowed...) - paths = append(paths, spec.Forbidden...) - paths = append(paths, spec.Paths...) - paths = append(paths, spec.Inputs...) - paths = append(paths, spec.Outputs...) - paths = append(paths, spec.Artifacts...) - return paths -} - -func validPattern(pattern string) bool { - return validRelativePath(pattern) && doublestar.ValidatePattern(filepath.ToSlash(pattern)) -} - -func validRelativePath(path string) bool { - if path == "" || filepath.IsAbs(path) { - return false - } - clean := filepath.Clean(path) - return clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) -} - -func validateRetry(path, owner string, retry ir.Retry) []parser.Diagnostic { - if retry.Attempts < 0 { - return []parser.Diagnostic{{Path: path, Message: owner + ": retry.attempts must be >= 0"}} - } - if retry.Backoff != "" { - if _, err := config.ParseDuration(retry.Backoff); err != nil { - return []parser.Diagnostic{{Path: path, Message: owner + ": retry.backoff: " + err.Error()}} - } - } - return nil -} - -func oneOf(got string, values ...string) bool { - for _, value := range values { - if got == value { - return true - } - } - return false -} - -func validateBoundaries(doc *ir.Document) []parser.Diagnostic { - var diags []parser.Diagnostic - for _, r := range doc.Requirements { - for _, a := range r.Boundaries.Allowed { - for _, f := range r.Boundaries.Forbidden { - if a == f { - diags = append(diags, parser.Diagnostic{ - Path: r.SourcePath, - Message: fmt.Sprintf("contradictory boundary path %q in allowed and forbidden", a), - }) - } - } - } - } - return diags -} - -func compilerWarnings(doc *ir.Document, cfg *config.Config) []parser.Diagnostic { - var diagnostics []parser.Diagnostic - status := map[string]string{} - for _, requirement := range doc.Requirements { - status[requirement.ID] = requirement.Status - } - for _, requirement := range doc.Requirements { - warn := func(message string) { - diagnostics = append(diagnostics, parser.Diagnostic{ - Severity: parser.SeverityWarning, Path: requirement.SourcePath, Message: message, - }) - } - if len(requirement.AppliesTo.Paths) == 0 { - warn("requirement has no applies_to.paths mapping") - } - if len(requirement.Owners) == 0 { - warn("requirement has no owner") - } - for _, pattern := range append(append([]string{}, requirement.Boundaries.Allowed...), requirement.Boundaries.Forbidden...) { - if pattern == "**" || pattern == "**/*" { - warn("requirement uses a broad file boundary " + fmt.Sprintf("%q", pattern)) - } - if security.IsTestPath(pattern) && containsPattern(requirement.Boundaries.Allowed, pattern) { - warn("test path is inside the allowed implementation boundary: " + pattern) - } - } - for _, dependency := range requirement.DependsOn { - if status[dependency] == "disabled" { - warn("active requirement references disabled requirement " + dependency) - } - } - for _, obligation := range requirement.Obligations { - var specs []ir.ProviderSpec - collectSpecs(obligation.Verify, &specs) - if len(specs) == 0 { - continue - } - probabilisticOnly := true - exitCodeOnly := true - for _, spec := range specs { - class := firstNonEmpty(spec.EvidenceClass, obligation.EvidenceClass, "deterministic") - if class != "probabilistic" { - probabilisticOnly = false - } - if spec.Provider != "command" || hasOutputExpectation(spec.Result) { - exitCodeOnly = false - } - if spec.Provider == "command" && spec.Timeout == "" && obligation.Timeout == "" && requirement.Timeout == "" && cfg.Verification.DefaultTimeout == "" { - warn(obligation.ID + ": command has no timeout") - } - run := strings.ToLower(spec.Run) - configuration, _ := ir.CanonicalJSON(spec.Configuration) - if literalCredential(spec.Run) || literalCredential(string(configuration)) || - environmentCredential(spec.Environment) { - warn(obligation.ID + ": verifier appears to contain a literal credential") - } - if strings.Contains(run, "git add") || strings.Contains(run, "git commit") || - strings.Contains(run, "sed -i") || strings.Contains(run, " >") || strings.Contains(run, "tee ") { - warn(obligation.ID + ": verification command may modify tracked files") - } - } - if probabilisticOnly { - warn(obligation.ID + ": obligation has only probabilistic evidence") - } - if exitCodeOnly { - warn(obligation.ID + ": obligation relies only on command exit codes") - } - } - } - return diagnostics -} - -func environmentCredential(environment map[string]string) bool { - for name, value := range environment { - lower := strings.ToLower(name) - if value != "" && (strings.Contains(lower, "token") || strings.Contains(lower, "secret") || - strings.Contains(lower, "password") || strings.Contains(lower, "api_key") || - strings.Contains(lower, "api-key") || strings.HasSuffix(lower, "_key")) { - return true - } - } - return false -} - -func literalCredential(command string) bool { - lower := strings.ToLower(command) - for _, marker := range []string{ - "token=", "token:", "secret=", "secret:", "password=", "password:", - "api_key=", "api_key:", "api-key=", "api-key:", - `"token":"`, `"secret":"`, `"password":"`, `"api_key":"`, - } { - index := strings.Index(lower, marker) - if index < 0 { - continue - } - value := strings.TrimLeft(strings.TrimSpace(command[index+len(marker):]), `"'`) - if value != "" && !strings.HasPrefix(value, "$") && !strings.HasPrefix(value, "${") && - !strings.HasPrefix(value, "[REDACTED]") { - return true - } - } - return false -} - -func collectSpecs(node ir.VerifyNode, specs *[]ir.ProviderSpec) { - if node.Provider != nil { - *specs = append(*specs, *node.Provider) - } - for _, child := range node.All { - collectSpecs(child, specs) - } - for _, child := range node.Any { - collectSpecs(child, specs) - } - if node.Not != nil { - collectSpecs(*node.Not, specs) - } -} - -func hasOutputExpectation(result map[string]any) bool { - if result == nil { - return false - } - return result["stdout"] != nil || result["stderr"] != nil -} - -func containsPattern(patterns []string, want string) bool { - for _, pattern := range patterns { - if pattern == want { - return true - } - } - return false -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} - -// WriteIR writes the document JSON to path. -func WriteIR(doc *ir.Document, path string) error { - if doc.Requirements == nil { - doc.Requirements = make([]ir.Requirement, 0) - } - if doc.Hash == "" { - if err := doc.ComputeHashes(); err != nil { - return err - } - } - if err := appschema.Validate("ir", doc); err != nil { - return err - } - if err := mkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - b, _ := ir.CanonicalJSON(doc) - temporary := filepath.Join(filepath.Dir(path), "."+filepath.Base(path)+".tmp-"+ulid.Make().String()) - if err := writeFile(temporary, append(b, '\n'), 0o644); err != nil { - return err - } - defer removeFile(temporary) - return renameFile(temporary, path) -} diff --git a/internal/compiler/compiler_coverage_test.go b/internal/compiler/compiler_coverage_test.go deleted file mode 100644 index 71abf58..0000000 --- a/internal/compiler/compiler_coverage_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package compiler_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/compiler" - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/ir" -) - -func writeReq(t *testing.T, root, name, body string) { - t.Helper() - dir := filepath.Join(root, ".intentci", "requirements") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestCompileLoadConfigNilAndFilter(t *testing.T) { - root := writeRepo(t, 2) - res, err := compiler.Compile(compiler.Options{Root: root, RequirementID: "REQ-001"}) - if err != nil { - t.Fatal(err) - } - if len(res.Document.Requirements) != 1 { - t.Fatalf("%d", len(res.Document.Requirements)) - } - // nil config loads from disk - res, err = compiler.Compile(compiler.Options{Root: root}) - if err != nil { - t.Fatal(err) - } - if len(res.Document.Requirements) != 2 { - t.Fatal(len(res.Document.Requirements)) - } -} - -func TestCompileMissingConfig(t *testing.T) { - _, err := compiler.Compile(compiler.Options{Root: t.TempDir()}) - if err == nil { - t.Fatal("expected error") - } -} - -func TestCompileCycleUnknownDepProvidersBoundaries(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, ".intentci"), 0o755); err != nil { - t.Fatal(err) - } - cfg := `version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md", ".intentci/requirements"] -` - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), []byte(cfg), 0o644); err != nil { - t.Fatal(err) - } - // also write non-md and a dir match noise - _ = os.WriteFile(filepath.Join(root, ".intentci", "requirements", "skip.txt"), []byte("x"), 0o644) - - a := `--- -id: REQ-A -title: a -status: active -priority: required -depends_on: [REQ-B, REQ-MISSING] ---- -# Intent -a -# Boundaries -` + "```yaml" + ` -allowed: [same] -forbidden: [same] -` + "```" + ` -# Obligations -` + "```yaml" + ` -- id: O1 - verify: - all: - - provider: nope - id: x - - provider: command - id: c - run: "" - - provider: boundary - id: b - - provider: command - id: ok - run: "true" - verify_unused: 1 -- id: O1 - verify: - any: - - provider: command - id: a2 - run: "true" - statement: dup -- id: O2 - verify: - not: - provider: command - id: n - run: "true" -` + "```" + ` -` - // fix - can't have verify twice. Rewrite properly. - a = `--- -id: REQ-A -title: a -status: active -priority: required -depends_on: [REQ-B, REQ-MISSING] ---- -# Intent -a -# Boundaries -` + "```yaml" + ` -allowed: [same] -forbidden: [same] -` + "```" + ` -# Obligations -` + "```yaml" + ` -- id: O1 - statement: s - verify: - all: - - provider: nope - id: x - - provider: command - id: c - run: "" - - provider: boundary - id: b - - provider: command - id: ok - run: "true" -- id: O1 - statement: dup - verify: - any: - - provider: command - id: a2 - run: "true" -- id: O2 - statement: n - verify: - not: - provider: command - id: n - run: "true" -` + "```" + ` -` - b := `--- -id: REQ-B -title: b -status: active -priority: required -depends_on: [REQ-A] ---- -# Intent -b -# Obligations -` + "```yaml" + ` -- id: O - verify: - provider: command - id: c - run: "true" -` + "```" + ` -` - writeReq(t, root, "a.md", a) - writeReq(t, root, "b.md", b) - res, err := compiler.Compile(compiler.Options{Root: root, Strict: true}) - if err == nil { - t.Fatalf("expected fail diags=%v", res.Diagnostics) - } - if len(res.Diagnostics) == 0 { - t.Fatal("expected diags") - } -} - -func TestWriteIRErrors(t *testing.T) { - doc := &ir.Document{SchemaVersion: 1, Project: "p"} - _ = doc.ComputeHashes() - // parent is a file - base := t.TempDir() - file := filepath.Join(base, "notdir") - if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := compiler.WriteIR(doc, filepath.Join(file, "ir.json")); err == nil { - t.Fatal("expected mkdir error") - } - if err := compiler.WriteIR(doc, filepath.Join(base, "ok", "ir.json")); err != nil { - t.Fatal(err) - } -} - -func TestDiscoverBadPattern(t *testing.T) { - root := t.TempDir() - cfg := config.Default() - cfg.Requirements.Paths = []string{"["} // invalid glob - if err := os.MkdirAll(filepath.Join(root, ".intentci"), 0o755); err != nil { - t.Fatal(err) - } - raw := []byte(`version: 1 -project: {name: demo} -requirements: - paths: ["["] -`) - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), raw, 0o644); err != nil { - t.Fatal(err) - } - _, err := compiler.Compile(compiler.Options{Root: root, Config: cfg}) - if err == nil { - t.Fatal("expected glob error") - } -} diff --git a/internal/compiler/compiler_internal_test.go b/internal/compiler/compiler_internal_test.go deleted file mode 100644 index db72835..0000000 --- a/internal/compiler/compiler_internal_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package compiler - -import ( - "errors" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/ir" -) - -func TestDiscoverAbsAndNonMD(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, "reqs") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "b.md"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - sub := filepath.Join(dir, "subdir.md") - if err := os.Mkdir(sub, 0o755); err != nil { - t.Fatal(err) - } - files, err := discover(root, []string{"reqs/**/*"}) - if err != nil { - t.Fatal(err) - } - if len(files) != 1 { - t.Fatalf("%v", files) - } - old := absPath - defer func() { absPath = old }() - absPath = func(string) (string, error) { return "", errors.New("abs") } - files, err = discover(root, []string{"reqs/**/*.md"}) - if err != nil || len(files) != 0 { - t.Fatalf("%v %v", files, err) - } -} - -func TestCompileHashErrorAndNonStrict(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, ".intentci", "requirements"), 0o755); err != nil { - t.Fatal(err) - } - cfgBody := `version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -` - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), []byte(cfgBody), 0o644); err != nil { - t.Fatal(err) - } - body := `--- -id: BAD -title: t -status: active -priority: required ---- -# Intent -i -# Obligations -` + "```yaml" + ` -- id: O - verify: - provider: command - id: c - run: "true" -` + "```" - if err := os.WriteFile(filepath.Join(root, ".intentci", "requirements", "a.md"), []byte(body), 0o644); err != nil { - t.Fatal(err) - } - cfg := config.Default() - cfg.Project.Name = "demo" - res, err := Compile(Options{Root: root, Config: cfg, Strict: false}) - if err == nil { - t.Fatalf("expected hasErrors path, diags=%v", res.Diagnostics) - } - - oldH := computeHashes - defer func() { computeHashes = oldH }() - computeHashes = func(*ir.Document) error { return errors.New("hash") } - body2 := `--- -id: REQ-1 -title: t -status: active -priority: required ---- -# Intent -i -# Obligations -` + "```yaml" + ` -- id: O - verify: - provider: command - id: c - run: "true" -` + "```" - root2 := t.TempDir() - _ = os.MkdirAll(filepath.Join(root2, ".intentci", "requirements"), 0o755) - _ = os.WriteFile(filepath.Join(root2, ".intentci", "config.yaml"), []byte(cfgBody), 0o644) - _ = os.WriteFile(filepath.Join(root2, ".intentci", "requirements", "a.md"), []byte(body2), 0o644) - if _, err := Compile(Options{Root: root2, Config: cfg}); err == nil { - t.Fatal("hash") - } - computeHashes = oldH - - doc := &ir.Document{SchemaVersion: 1, Project: "p", Requirements: []ir.Requirement{{ - ID: "R", Obligations: []ir.Obligation{{ID: "O", Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "command", Extra: map[string]any{"ch": make(chan int)}, - }}}}, - }}} - oldW := writeFile - defer func() { writeFile = oldW }() - writeFile = func(string, []byte, os.FileMode) error { return errors.New("write") } - doc2 := &ir.Document{SchemaVersion: 1, Project: "p"} - _ = doc2.ComputeHashes() - if err := WriteIR(doc2, filepath.Join(t.TempDir(), "ir.json")); err == nil { - t.Fatal("write") - } - writeFile = oldW - if err := WriteIR(doc, filepath.Join(t.TempDir(), "ir.json")); err == nil { - t.Fatal("canonical") - } -} diff --git a/internal/compiler/compiler_test.go b/internal/compiler/compiler_test.go deleted file mode 100644 index 3418796..0000000 --- a/internal/compiler/compiler_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package compiler_test - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/compiler" - "github.com/hypertrial/intentci/internal/config" -) - -func writeRepo(t *testing.T, n int) string { - t.Helper() - root := t.TempDir() - dir := filepath.Join(root, ".intentci", "requirements") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - cfg := config.Default() - cfg.Project.Name = "demo" - raw := []byte(`version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -`) - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), raw, 0o644); err != nil { - t.Fatal(err) - } - for i := 1; i <= n; i++ { - body := fmt.Sprintf(`--- -id: REQ-%03d -title: Requirement %d -status: active -priority: required -owners: [test] -applies_to: - paths: ["**"] ---- - -# Intent - -Intent %d - -# Obligations - -`+"```yaml"+` -- id: OBL-001 - statement: smoke - required: true - verify: - all: - - provider: command - id: smoke - run: "printf ok" - result: - type: exit_code - equals: 0 - stdout: {contains: ok} -`+"```"+` -`, i, i, i) - if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("REQ-%03d.md", i)), []byte(body), 0o644); err != nil { - t.Fatal(err) - } - } - return root -} - -func TestCompile100(t *testing.T) { - root := writeRepo(t, 100) - res, err := compiler.Compile(compiler.Options{Root: root, Strict: true}) - if err != nil { - t.Fatalf("%v diags=%v", err, res.Diagnostics) - } - if len(res.Document.Requirements) != 100 { - t.Fatalf("got %d", len(res.Document.Requirements)) - } - if res.Document.Hash == "" { - t.Fatal("missing hash") - } - out := filepath.Join(t.TempDir(), "ir.json") - if err := compiler.WriteIR(res.Document, out); err != nil { - t.Fatal(err) - } -} - -func TestCompileDuplicateAndCycle(t *testing.T) { - root := writeRepo(t, 1) - // add duplicate - body := `--- -id: REQ-001 -title: dup -status: active -priority: required ---- -# Intent -x -# Obligations -` + "```yaml" + ` -- id: OBL-001 - statement: s - verify: - all: - - provider: command - id: c - run: true -` + "```" - _ = os.WriteFile(filepath.Join(root, ".intentci", "requirements", "dup.md"), []byte(body), 0o644) - res, err := compiler.Compile(compiler.Options{Root: root, Strict: true}) - if err == nil { - t.Fatalf("expected error, diags=%v", res.Diagnostics) - } -} - -func TestCompileIsDeterministic(t *testing.T) { - root := writeRepo(t, 1) - var want []byte - for i := 0; i < 25; i++ { - res, err := compiler.Compile(compiler.Options{Root: root}) - if err != nil { - t.Fatal(err) - } - out := filepath.Join(t.TempDir(), "ir.json") - if err := compiler.WriteIR(res.Document, out); err != nil { - t.Fatal(err) - } - got, err := os.ReadFile(out) - if err != nil { - t.Fatal(err) - } - if i == 0 { - want = got - } else if !bytes.Equal(got, want) { - t.Fatalf("compile %d was nondeterministic", i) - } - } -} diff --git a/internal/compiler/compiler_v1_internal_test.go b/internal/compiler/compiler_v1_internal_test.go deleted file mode 100644 index ca7a493..0000000 --- a/internal/compiler/compiler_v1_internal_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package compiler - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/ir" -) - -func TestV1ValidationBranches(t *testing.T) { - confidenceHigh := 1.1 - confidenceValid := 0.8 - document := &ir.Document{Requirements: []ir.Requirement{ - { - ID: "A", Status: "active", SourcePath: "a.md", - DependsOn: []string{"B", "missing"}, - AppliesTo: ir.AppliesTo{Paths: []string{"../escape"}}, - Boundaries: ir.Boundaries{Allowed: []string{"[", "same"}, Forbidden: []string{"same"}}, - Timeout: "never", - Obligations: []ir.Obligation{ - { - ID: "one", DependsOn: []string{"two", "missing"}, EvidenceClass: "bogus", - Severity: "bogus", ConfidenceThreshold: &confidenceHigh, Timeout: "never", - Retry: ir.Retry{Attempts: -1}, Platforms: []string{"windows"}, - }, - { - ID: "two", DependsOn: []string{"one"}, EvidenceClass: "deterministic", - ConfidenceThreshold: &confidenceValid, Retry: ir.Retry{Backoff: "never"}, - }, - {ID: "duplicate"}, - {ID: "duplicate"}, - }, - }, - {ID: "B", DependsOn: []string{"A"}, SourcePath: "b.md"}, - }} - if got := validateGraph(document); len(got) < 5 { - t.Fatalf("graph diagnostics: %+v", got) - } - if got := validateRequirements(document); len(got) < 10 { - t.Fatalf("requirement diagnostics: %+v", got) - } - if got := validateBoundaries(document); len(got) != 1 { - t.Fatalf("boundary diagnostics: %+v", got) - } - if obligationCycle([]ir.Obligation{{ID: "ok"}, {ID: "other", DependsOn: []string{"ok"}}}) != "" { - t.Fatal("acyclic obligations rejected") - } - if !validLocalID("a-B_2.0") || validLocalID("") || validLocalID("../bad") { - t.Fatal("local id validation") - } - for _, value := range []string{"ok/**", "x"} { - if !validPattern(value) || !validRelativePath(value) { - t.Fatal(value) - } - } - for _, value := range []string{"", "../x", "/x", "["} { - if validPattern(value) { - t.Fatal(value) - } - } - if len(validateRetry("p", "o", ir.Retry{})) != 0 { - t.Fatal("empty retry") - } - if !oneOf("x", "a", "x") || oneOf("x", "a", "b") { - t.Fatal("oneOf") - } -} - -func TestV1ProviderValidationBranches(t *testing.T) { - root := t.TempDir() - if err := os.Mkdir(filepath.Join(root, "work"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "report.json"), []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - oldLookPath := lookPath - lookPath = func(name string) (string, error) { - if name == "intentci-provider-found" { - return "/bin/true", nil - } - return "", errors.New("missing") - } - defer func() { lookPath = oldLookPath }() - - providerNode := func(spec ir.ProviderSpec) ir.VerifyNode { - return ir.VerifyNode{Provider: &spec} - } - requirement := ir.Requirement{ - ID: "REQ", SourcePath: "r.md", - Obligations: []ir.Obligation{ - {ID: "bad", Verify: ir.VerifyNode{All: []ir.VerifyNode{ - providerNode(ir.ProviderSpec{ - Provider: "../bad", ID: "../bad", Allowed: []string{"["}, - WorkingDirectory: "../bad", Report: "/absolute", - InheritEnv: []string{"["}, Environment: map[string]string{"BAD=NAME": "x"}, - Timeout: "never", Retry: ir.Retry{Attempts: -1}, - }), - providerNode(ir.ProviderSpec{ - Provider: "command", ID: "same", WorkingDirectory: "missing", - Report: "missing.xml", InheritEnv: []string{"PATH"}, - Environment: map[string]string{"": "x"}, Retry: ir.Retry{Backoff: "never"}, - }), - providerNode(ir.ProviderSpec{Provider: "command", ID: "same", Run: "different"}), - providerNode(ir.ProviderSpec{Provider: "found", ID: "found", WorkingDirectory: "work"}), - providerNode(ir.ProviderSpec{Provider: "missing", ID: "missing"}), - }}}, - {ID: "deps", Verify: ir.VerifyNode{Any: []ir.VerifyNode{ - providerNode(ir.ProviderSpec{Provider: "command", ID: "cycle-a", Run: "true", DependsOn: []string{"cycle-b", "absent"}}), - providerNode(ir.ProviderSpec{Provider: "command", ID: "cycle-b", Run: "true", DependsOn: []string{"cycle-a"}}), - }}}, - {ID: "not", Verify: ir.VerifyNode{Not: ptrNode(providerNode(ir.ProviderSpec{ - Provider: "json", ID: "json", Report: "report.json", - Assert: map[string]any{"path": "$.x", "operator": "exists"}, - }))}}, - }, - } - document := &ir.Document{Requirements: []ir.Requirement{requirement}} - diagnostics := validateProviders(root, document) - if len(diagnostics) < 12 { - t.Fatalf("provider diagnostics: %+v", diagnostics) - } - - var ids []string - collectProviderIDs(document.Requirements[0].Obligations[0].Verify, func(id string) { - ids = append(ids, id) - }) - if len(ids) != 5 { - t.Fatalf("%v", ids) - } - paths := providerPaths(ir.ProviderSpec{ - Allowed: []string{"a"}, Forbidden: []string{"b"}, Paths: []string{"c"}, - Inputs: []string{"d"}, Outputs: []string{"e"}, Artifacts: []string{"f"}, - }) - if strings.Join(paths, "") != "abcdef" { - t.Fatalf("%v", paths) - } -} - -func TestV1CompilerWarnings(t *testing.T) { - cfg := config.Default() - cfg.Verification.DefaultTimeout = "" - document := &ir.Document{Requirements: []ir.Requirement{ - { - ID: "disabled", Status: "disabled", - }, - { - ID: "active", Status: "active", SourcePath: "r.md", DependsOn: []string{"disabled"}, - Boundaries: ir.Boundaries{ - Allowed: []string{"**", "tests/**"}, - Forbidden: []string{"**/*"}, - }, - Obligations: []ir.Obligation{ - {ID: "empty"}, - {ID: "prob", EvidenceClass: "probabilistic", Verify: ir.VerifyNode{ - Provider: &ir.ProviderSpec{ - Provider: "command", Run: "TOKEN=literal git add x > y", - Environment: map[string]string{"API_KEY": "literal"}, - }, - }}, - {ID: "expected", Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "command", Run: "true", Result: map[string]any{"stdout": map[string]any{"contains": "x"}}, - }}}, - }, - }, - }} - diagnostics := compilerWarnings(document, cfg) - messages := make([]string, 0, len(diagnostics)) - for _, diagnostic := range diagnostics { - if diagnostic.Severity != "warning" { - t.Fatalf("%+v", diagnostic) - } - messages = append(messages, diagnostic.Message) - } - joined := strings.Join(messages, "\n") - for _, want := range []string{ - "no applies_to", "no owner", "broad file boundary", "test path", - "disabled requirement", "only probabilistic", "only on command exit", - "no timeout", "literal credential", "may modify tracked", - } { - if !strings.Contains(joined, want) { - t.Fatalf("missing %q in:\n%s", want, joined) - } - } - if environmentCredential(map[string]string{"PATH": "/bin"}) || - !environmentCredential(map[string]string{"PASSWORD": "x"}) { - t.Fatal("environment credential") - } - for _, value := range []string{"token=$TOKEN", `token="[REDACTED]"`, "plain"} { - if literalCredential(value) { - t.Fatal(value) - } - } - if !literalCredential("password: visible") || - !containsPattern([]string{"a", "b"}, "b") || - containsPattern([]string{"a"}, "b") || - firstNonEmpty("", "x", "y") != "x" || firstNonEmpty("", "") != "" || - hasOutputExpectation(nil) || hasOutputExpectation(map[string]any{}) || - !hasOutputExpectation(map[string]any{"stderr": "x"}) { - t.Fatal("warning helpers") - } -} - -func TestV1CompileWorkingDirectoryAndWriteValidation(t *testing.T) { - root := t.TempDir() - cfg := config.Default() - cfg.Project.Name = "p" - cfg.Verification.WorkingDirectory = "missing" - result, err := Compile(Options{Root: root, Config: cfg}) - if err == nil || len(result.Diagnostics) == 0 { - t.Fatalf("result=%+v err=%v", result, err) - } - - acyclic := ir.Requirement{SourcePath: "r.md", Obligations: []ir.Obligation{{ - Verify: ir.VerifyNode{All: []ir.VerifyNode{ - {Provider: &ir.ProviderSpec{Provider: "command", ID: "a", DependsOn: []string{"leaf"}}}, - {Provider: &ir.ProviderSpec{Provider: "command", ID: "b", DependsOn: []string{"leaf"}}}, - {Provider: &ir.ProviderSpec{Provider: "command", ID: "leaf"}}, - }}, - }}} - if diagnostics := validateVerifierGraph(acyclic); len(diagnostics) != 0 { - t.Fatalf("%+v", diagnostics) - } - - invalid := &ir.Document{SchemaVersion: 2, Project: "p", Hash: "set", Requirements: []ir.Requirement{}} - if err := WriteIR(invalid, filepath.Join(root, "invalid.json")); err == nil { - t.Fatal("schema-invalid IR written") - } -} - -func ptrNode(node ir.VerifyNode) *ir.VerifyNode { return &node } diff --git a/internal/compiler/fuzz_test.go b/internal/compiler/fuzz_test.go deleted file mode 100644 index 199d745..0000000 --- a/internal/compiler/fuzz_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package compiler - -import ( - "reflect" - "testing" - - "github.com/hypertrial/intentci/internal/ir" -) - -func FuzzV1DependencyGraphs(f *testing.F) { - f.Add([]byte{0, 1, 2}, false) - f.Add([]byte{1, 0}, true) - f.Fuzz(func(t *testing.T, edges []byte, selfCycle bool) { - if len(edges) > 64 { - t.Skip() - } - count := len(edges)%8 + 1 - document := &ir.Document{SchemaVersion: ir.SchemaVersion} - for index := 0; index < count; index++ { - requirement := ir.Requirement{ID: "R" + string(rune('A'+index))} - if len(edges) > 0 { - target := int(edges[index%len(edges)]) % count - if target != index { - requirement.DependsOn = []string{"R" + string(rune('A'+target))} - } - } - document.Requirements = append(document.Requirements, requirement) - } - if selfCycle { - document.Requirements[0].DependsOn = []string{document.Requirements[0].ID} - } - first := validateGraph(document) - second := validateGraph(document) - if !reflect.DeepEqual(first, second) { - t.Fatalf("dependency diagnostics are nondeterministic:\n%+v\n%+v", first, second) - } - if selfCycle && len(first) == 0 { - t.Fatal("self dependency was not rejected") - } - }) -} diff --git a/internal/compiler/mutation_internal_test.go b/internal/compiler/mutation_internal_test.go deleted file mode 100644 index 6adf39f..0000000 --- a/internal/compiler/mutation_internal_test.go +++ /dev/null @@ -1,260 +0,0 @@ -package compiler - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/initcmd" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/parser" -) - -func TestMutationSensitiveCompilationSelectionAndSourcePath(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - first := filepath.Join(root, ".intentci", "requirements", "REQ-001.md") - second := strings.ReplaceAll(string(readTestFile(t, first)), "REQ-001", "REQ-002") - if err := os.WriteFile(filepath.Join(filepath.Dir(first), "REQ-002.md"), []byte(second), 0o644); err != nil { - t.Fatal(err) - } - result, err := Compile(Options{Root: root, RequirementID: "REQ-002"}) - if err != nil { - t.Fatal(err) - } - if len(result.Document.Requirements) != 1 || - result.Document.Requirements[0].ID != "REQ-002" || - result.Document.Requirements[0].SourcePath != ".intentci/requirements/REQ-002.md" { - t.Fatalf("filtered compilation = %#v", result.Document.Requirements) - } -} - -func TestMutationSensitiveRequirementMetadataValidation(t *testing.T) { - zero, one, below, above, half := 0.0, 1.0, -0.1, 1.1, 0.5 - document := &ir.Document{Requirements: []ir.Requirement{{ - ID: "REQ", SourcePath: "requirement.md", Timeout: "invalid", - Obligations: []ir.Obligation{ - {ID: "ZERO", EvidenceClass: "probabilistic", ConfidenceThreshold: &zero}, - {ID: "ONE", EvidenceClass: "probabilistic", ConfidenceThreshold: &one}, - {ID: "BELOW", EvidenceClass: "probabilistic", ConfidenceThreshold: &below}, - {ID: "ABOVE", EvidenceClass: "probabilistic", ConfidenceThreshold: &above}, - {ID: "CLASS", EvidenceClass: "deterministic", ConfidenceThreshold: &half}, - {ID: "TIMEOUT", Timeout: "invalid"}, - {ID: "RETRY", Retry: ir.Retry{Backoff: "invalid"}}, - }, - }}} - diagnostics := validateRequirements(document) - for _, expected := range []string{ - "timeout:", "BELOW: confidence_threshold", "ABOVE: confidence_threshold", - "CLASS: confidence_threshold requires", "TIMEOUT: timeout:", "RETRY: retry.backoff:", - } { - if !diagnosticsContain(diagnostics, expected) { - t.Fatalf("missing %q in %#v", expected, diagnostics) - } - } - for _, unexpected := range []string{"ZERO:", "ONE:"} { - if diagnosticsContain(diagnostics, unexpected) { - t.Fatalf("valid confidence boundary rejected: %#v", diagnostics) - } - } - if countDiagnostics(diagnostics, "timeout:") != 2 { - t.Fatalf("requirement and obligation timeout diagnostics = %#v", diagnostics) - } -} - -func TestMutationSensitiveProviderValidation(t *testing.T) { - root := t.TempDir() - if err := os.Mkdir(filepath.Join(root, "work"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "report.xml"), []byte(""), 0o644); err != nil { - t.Fatal(err) - } - oldLookPath := lookPath - defer func() { lookPath = oldLookPath }() - lookPath = func(name string) (string, error) { - if name == "intentci-provider-custom" { - return "/bin/true", nil - } - return "", errors.New("missing") - } - valid := ir.ProviderSpec{ - Provider: "custom", ID: "aZ09-_.", WorkingDirectory: "work", - InheritEnv: []string{"PATH"}, Environment: map[string]string{"VALID": "value"}, - Timeout: "1s", - } - validReport := ir.ProviderSpec{Provider: "junit", ID: "report", Report: "report.xml"} - document := providerDocument(valid, validReport) - if diagnostics := validateProviders(root, document); len(diagnostics) != 0 { - t.Fatalf("valid providers rejected: %#v", diagnostics) - } - - if err := os.WriteFile(filepath.Join(root, "not-a-directory"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(root, "report-directory"), 0o755); err != nil { - t.Fatal(err) - } - invalid := []ir.ProviderSpec{ - {Provider: "missing", ID: "bad/"}, - {Provider: "command", ID: "missing-work", WorkingDirectory: "missing"}, - {Provider: "command", ID: "file-work", WorkingDirectory: "not-a-directory"}, - {Provider: "junit", ID: "missing-report", Report: "missing.xml"}, - {Provider: "junit", ID: "directory-report", Report: "report-directory"}, - {Provider: "command", ID: "inherit", InheritEnv: []string{"["}}, - {Provider: "command", ID: "environment", Environment: map[string]string{"": "x"}}, - {Provider: "command", ID: "timeout", Timeout: "invalid"}, - } - document = providerDocument(invalid...) - diagnostics := validateProviders(root, document) - for _, expected := range []string{ - "unsupported provider", "invalid verifier id", "working_directory does not exist", - "referenced report does not exist", "invalid inherited environment", - "invalid environment name", "timeout:", - } { - if !diagnosticsContain(diagnostics, expected) { - t.Fatalf("missing %q in %#v", expected, diagnostics) - } - } -} - -func TestMutationSensitiveVerifierIdentifiersAndGraphs(t *testing.T) { - for _, value := range []string{"a", "z", "A", "Z", "0", "9", "-", "_", ".", "aZ09-_."} { - if !validLocalID(value) { - t.Fatalf("valid id %q rejected", value) - } - } - for _, value := range []string{"", "`", "{", "@", "[", "/", "="} { - if validLocalID(value) { - t.Fatalf("invalid id %q accepted", value) - } - } - requirement := ir.Requirement{ - SourcePath: "requirement.md", - Obligations: []ir.Obligation{ - {Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", ID: "same", Run: "true"}}}, - {Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", ID: "same", Run: "false"}}}, - }, - } - if diagnostics := validateVerifierGraph(requirement); !diagnosticsContain(diagnostics, "incompatible configuration") { - t.Fatalf("incompatible verifier reuse accepted: %#v", diagnostics) - } - requirement.Obligations[1].Verify.Provider.Run = "true" - if diagnostics := validateVerifierGraph(requirement); len(diagnostics) != 0 { - t.Fatalf("identical verifier reuse rejected: %#v", diagnostics) - } - document := &ir.Document{Requirements: []ir.Requirement{{ - ID: "REQ", SourcePath: "requirement.md", Obligations: []ir.Obligation{{ - ID: "OBL", Verify: ir.VerifyNode{All: []ir.VerifyNode{ - {Provider: &ir.ProviderSpec{Provider: "command", ID: "duplicate", Run: "true"}}, - {Provider: &ir.ProviderSpec{Provider: "command", ID: "duplicate", Run: "true"}}, - }}, - }}, - }}} - if diagnostics := validateProviders(t.TempDir(), document); !diagnosticsContain(diagnostics, "duplicate verifier id") { - t.Fatalf("duplicate verifier id not diagnosed: %#v", diagnostics) - } -} - -func TestMutationSensitiveRetryBoundaryAndWarnings(t *testing.T) { - if diagnostics := validateRetry("r.md", "OBL", ir.Retry{Backoff: "1s"}); len(diagnostics) != 0 { - t.Fatalf("valid retry rejected: %#v", diagnostics) - } - if diagnostics := validateRetry("r.md", "OBL", ir.Retry{Backoff: "bad"}); !diagnosticsContain(diagnostics, "retry.backoff") { - t.Fatalf("invalid retry accepted: %#v", diagnostics) - } - document := &ir.Document{Requirements: []ir.Requirement{{ - ID: "REQ", SourcePath: "r.md", - Boundaries: ir.Boundaries{Allowed: []string{"src/**"}, Forbidden: []string{"src/**"}}, - }}} - if diagnostics := validateBoundaries(document); !diagnosticsContain(diagnostics, "contradictory boundary") { - t.Fatalf("contradictory boundary accepted: %#v", diagnostics) - } - document.Requirements[0].Boundaries = ir.Boundaries{ - Allowed: []string{"**", "**/*"}, Forbidden: []string{"other/**"}, - } - warnings := compilerWarnings(document, config.Default()) - if countDiagnostics(warnings, "broad file boundary") != 2 { - t.Fatalf("broad boundary warnings = %#v", warnings) - } - if !diagnosticsContain(warnings, `broad file boundary "**"`) || - !diagnosticsContain(warnings, `broad file boundary "**/*"`) || - diagnosticsContain(warnings, `broad file boundary "other/**"`) { - t.Fatalf("broad boundary identities = %#v", warnings) - } -} - -func TestMutationSensitiveWriteIRComputesHash(t *testing.T) { - document := &ir.Document{ - SchemaVersion: 1, Project: "project", Requirements: []ir.Requirement{}, - } - path := filepath.Join(t.TempDir(), "ir.json") - if err := WriteIR(document, path); err != nil { - t.Fatal(err) - } - if document.Hash == "" { - t.Fatal("WriteIR did not compute the document hash") - } - if _, err := os.Stat(path); err != nil { - t.Fatal(err) - } - bad := &ir.Document{ - SchemaVersion: 1, Project: "project", - Requirements: []ir.Requirement{{ - ID: "REQ", Obligations: []ir.Obligation{{ - ID: "OBL", Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "command", Extra: map[string]any{"bad": make(chan int)}, - }}, - }}, - }}, - } - if err := WriteIR(bad, filepath.Join(t.TempDir(), "bad.json")); err == nil { - t.Fatal("hash computation failure ignored") - } -} - -func providerDocument(specs ...ir.ProviderSpec) *ir.Document { - obligations := make([]ir.Obligation, 0, len(specs)) - for index := range specs { - spec := specs[index] - obligations = append(obligations, ir.Obligation{ - ID: "OBL", Verify: ir.VerifyNode{Provider: &spec}, - }) - } - return &ir.Document{Requirements: []ir.Requirement{{ - ID: "REQ", SourcePath: "requirement.md", Obligations: obligations, - }}} -} - -func diagnosticsContain(diagnostics []parser.Diagnostic, want string) bool { - for _, diagnostic := range diagnostics { - if strings.Contains(diagnostic.Message, want) { - return true - } - } - return false -} - -func countDiagnostics(diagnostics []parser.Diagnostic, want string) int { - count := 0 - for _, diagnostic := range diagnostics { - if strings.Contains(diagnostic.Message, want) { - count++ - } - } - return count -} - -func readTestFile(t *testing.T, path string) []byte { - t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return raw -} diff --git a/internal/config/config.go b/internal/config/config.go index 8ab827d..230614f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,391 +1,110 @@ package config import ( - "encoding/json" + "errors" "fmt" + "io" "os" - "path/filepath" - "strconv" + "path" + "regexp" "strings" - "time" "github.com/bmatcuk/doublestar/v4" "gopkg.in/yaml.v3" ) -const ( - DirName = ".intentci" - ConfigFileName = "config.yaml" - LocalFileName = "config.local.yaml" -) - -// Config is the project configuration (.intentci/config.yaml). -type Config struct { - Version int `yaml:"version" json:"version"` - Project Project `yaml:"project" json:"project"` - Requirements RequirementsCfg `yaml:"requirements" json:"requirements"` - Verification VerificationCfg `yaml:"verification" json:"verification"` - ChangeImpact ChangeImpactCfg `yaml:"change_impact" json:"change_impact"` - Evidence EvidenceCfg `yaml:"evidence" json:"evidence"` - Repair RepairCfg `yaml:"repair" json:"repair"` - CI CICfg `yaml:"ci" json:"ci"` - Telemetry TelemetryCfg `yaml:"telemetry" json:"telemetry"` -} - -type Project struct { - Name string `yaml:"name" json:"name"` -} - -type RequirementsCfg struct { - Paths []string `yaml:"paths" json:"paths"` -} - -type VerificationCfg struct { - DefaultTimeout string `yaml:"default_timeout" json:"default_timeout"` - MaxParallel int `yaml:"max_parallel" json:"max_parallel"` - FailFast bool `yaml:"fail_fast" json:"fail_fast"` - WorkingDirectory string `yaml:"working_directory" json:"working_directory"` - RequireCleanWorktree bool `yaml:"require_clean_worktree" json:"require_clean_worktree"` -} - -type ChangeImpactCfg struct { - BaseRef string `yaml:"base_ref" json:"base_ref"` - IncludeUntracked bool `yaml:"include_untracked" json:"include_untracked"` - RunUnmappedRequirements bool `yaml:"run_unmapped_requirements" json:"run_unmapped_requirements"` - FailOnUnmapped bool `yaml:"fail_on_unmapped" json:"fail_on_unmapped"` - GlobalPaths []string `yaml:"global_paths" json:"global_paths"` -} - -type EvidenceCfg struct { - Directory string `yaml:"directory" json:"directory"` - RetainStdout bool `yaml:"retain_stdout" json:"retain_stdout"` - RetainStderr bool `yaml:"retain_stderr" json:"retain_stderr"` - HashAlgorithm string `yaml:"hash_algorithm" json:"hash_algorithm"` - Redact RedactCfg `yaml:"redact" json:"redact"` -} +const FileName = ".intentci.yaml" -type RedactCfg struct { - Environment []string `yaml:"environment" json:"environment"` -} - -type RepairCfg struct { - MaxAttempts int `yaml:"max_attempts" json:"max_attempts"` - StopOnRepeatedDiff bool `yaml:"stop_on_repeated_diff" json:"stop_on_repeated_diff"` - StopOnRepeatedFailure bool `yaml:"stop_on_repeated_failure" json:"stop_on_repeated_failure"` - AllowRequirementChanges bool `yaml:"allow_requirement_changes" json:"allow_requirement_changes"` - AllowTestChanges bool `yaml:"allow_test_changes" json:"allow_test_changes"` - ProtectedPaths []string `yaml:"protected_paths" json:"protected_paths"` -} +var idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) -type CICfg struct { - FailOn []string `yaml:"fail_on" json:"fail_on"` -} - -type TelemetryCfg struct { - Enabled bool `yaml:"enabled" json:"enabled"` -} - -// Default returns built-in defaults. -func Default() *Config { - return &Config{ - Version: 1, - Project: Project{Name: "project"}, - Requirements: RequirementsCfg{ - Paths: []string{".intentci/requirements/**/*.md"}, - }, - Verification: VerificationCfg{ - DefaultTimeout: "10m", - MaxParallel: 4, - WorkingDirectory: ".", - }, - ChangeImpact: ChangeImpactCfg{ - BaseRef: "origin/main", - IncludeUntracked: true, - GlobalPaths: []string{ - ".intentci/config.yaml", ".intentci/providers/**", ".intentci/schemas/**", - "go.mod", "go.sum", "package.json", "package-lock.json", "pnpm-lock.yaml", - "yarn.lock", "pyproject.toml", "requirements*.txt", "uv.lock", - "Cargo.toml", "Cargo.lock", "pom.xml", "build.gradle*", "gradle.lockfile", - }, - }, - Evidence: EvidenceCfg{ - Directory: ".intentci/runs", - RetainStdout: true, - RetainStderr: true, - HashAlgorithm: "sha256", - Redact: RedactCfg{ - Environment: []string{"*TOKEN*", "*SECRET*", "*PASSWORD*", "*KEY*"}, - }, - }, - Repair: RepairCfg{ - MaxAttempts: 3, - StopOnRepeatedDiff: true, - StopOnRepeatedFailure: true, - AllowTestChanges: true, - }, - CI: CICfg{ - FailOn: []string{"fail", "error", "unproven", "uncertain", "review_required"}, - }, - Telemetry: TelemetryCfg{Enabled: false}, - } -} - -// Dir returns the .intentci directory under root. -func Dir(root string) string { - return filepath.Join(root, DirName) +type Config struct { + Version int `yaml:"version"` + Checks []Check `yaml:"checks"` } -// Path returns the primary config path. -func Path(root string) string { - return filepath.Join(Dir(root), ConfigFileName) +type Check struct { + ID string `yaml:"id"` + Intent string `yaml:"intent"` + Paths []string `yaml:"paths"` + Run string `yaml:"run"` } -// Load reads config.yaml and optional config.local.yaml from root. func Load(root string) (*Config, error) { - cfg := Default() - primary := Path(root) - data, err := os.ReadFile(primary) + file, err := os.Open(path.Join(root, FileName)) if err != nil { - return nil, fmt.Errorf("read config: %w", err) - } - if err := decode(data, cfg); err != nil { - return nil, fmt.Errorf("parse config: %w", err) - } - local := filepath.Join(Dir(root), LocalFileName) - if b, err := os.ReadFile(local); err == nil { - if err := decode(b, cfg); err != nil { - return nil, fmt.Errorf("parse config.local.yaml: %w", err) - } - } else if !os.IsNotExist(err) { - return nil, fmt.Errorf("read config.local.yaml: %w", err) + return nil, fmt.Errorf("read %s: %w", FileName, err) } - if err := applyEnvironment(cfg); err != nil { - return nil, err - } - if err := cfg.Validate(); err != nil { - return nil, err - } - return cfg, nil -} + defer file.Close() -// Validate checks required fields and timeouts. -func (c *Config) Validate() error { - if c.Version != 1 { - return fmt.Errorf("unsupported config version %d (want 1)", c.Version) - } - if c.Project.Name == "" { - return fmt.Errorf("project.name is required") + var cfg Config + decoder := yaml.NewDecoder(file) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", FileName, err) } - if len(c.Requirements.Paths) == 0 { - return fmt.Errorf("requirements.paths must not be empty") - } - patterns := append(append([]string{}, c.Requirements.Paths...), c.ChangeImpact.GlobalPaths...) - patterns = append(patterns, c.Repair.ProtectedPaths...) - for _, pattern := range patterns { - if !validRelative(pattern) || !doublestar.ValidatePattern(filepath.ToSlash(pattern)) { - return fmt.Errorf("invalid path pattern %q", pattern) - } - } - for _, pattern := range c.Evidence.Redact.Environment { - if !doublestar.ValidatePattern(pattern) { - return fmt.Errorf("invalid evidence.redact.environment pattern %q", pattern) + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple YAML documents are not allowed") } + return nil, fmt.Errorf("parse %s: %w", FileName, err) } - if _, err := ParseDuration(c.Verification.DefaultTimeout); err != nil { - return fmt.Errorf("verification.default_timeout: %w", err) - } - if c.Verification.MaxParallel < 0 { - return fmt.Errorf("verification.max_parallel must be >= 0") - } - if !validRelative(c.Verification.WorkingDirectory) { - return fmt.Errorf("verification.working_directory must be repository-relative") - } - if c.Evidence.Directory == "" { - return fmt.Errorf("evidence.directory is required") - } - if !validRelative(c.Evidence.Directory) { - return fmt.Errorf("evidence.directory must be repository-relative") - } - if c.Evidence.HashAlgorithm != "sha256" { - return fmt.Errorf("evidence.hash_algorithm must be sha256") - } - if c.Repair.MaxAttempts < 1 { - return fmt.Errorf("repair.max_attempts must be >= 1") - } - for _, value := range c.CI.FailOn { - if !oneOf(value, "fail", "error", "unproven", "uncertain", "review_required") { - return fmt.Errorf("ci.fail_on contains invalid verdict %q", value) - } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("%s: %w", FileName, err) } - return nil + return &cfg, nil } -func validRelative(value string) bool { - if value == "" || filepath.IsAbs(value) { - return false +func (c Config) Validate() error { + if c.Version != 2 { + return fmt.Errorf("version must be 2") } - clean := filepath.Clean(value) - return clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) -} - -func oneOf(value string, allowed ...string) bool { - for _, candidate := range allowed { - if value == candidate { - return true - } + if len(c.Checks) == 0 { + return fmt.Errorf("checks must not be empty") } - return false -} - -func decode(data []byte, out any) error { - dec := yaml.NewDecoder(strings.NewReader(string(data))) - dec.KnownFields(true) - return dec.Decode(out) -} - -var lookupEnv = os.LookupEnv - -func applyEnvironment(c *Config) error { - stringValue := func(name string, dst *string) { - if value, ok := lookupEnv(name); ok { - *dst = value + ids := make(map[string]bool, len(c.Checks)) + for index, check := range c.Checks { + label := fmt.Sprintf("checks[%d]", index) + if !idPattern.MatchString(check.ID) { + return fmt.Errorf("%s.id %q must match %s", label, check.ID, idPattern) } - } - boolValue := func(name string, dst *bool) error { - value, ok := lookupEnv(name) - if !ok { - return nil - } - parsed, err := strconv.ParseBool(value) - if err != nil { - return fmt.Errorf("%s: %w", name, err) - } - *dst = parsed - return nil - } - intValue := func(name string, dst *int) error { - value, ok := lookupEnv(name) - if !ok { - return nil - } - parsed, err := strconv.Atoi(value) - if err != nil { - return fmt.Errorf("%s: %w", name, err) + if ids[check.ID] { + return fmt.Errorf("duplicate check id %q", check.ID) } - *dst = parsed - return nil - } - listValue := func(name string, dst *[]string) error { - value, ok := lookupEnv(name) - if !ok { - return nil + ids[check.ID] = true + if strings.TrimSpace(check.Intent) == "" { + return fmt.Errorf("%s.intent must not be empty", label) } - if err := json.Unmarshal([]byte(value), dst); err != nil { - return fmt.Errorf("%s: %w", name, err) + if strings.TrimSpace(check.Run) == "" { + return fmt.Errorf("%s.run must not be empty", label) } - return nil - } - - if err := intValue("INTENTCI_VERSION", &c.Version); err != nil { - return err - } - stringValue("INTENTCI_PROJECT_NAME", &c.Project.Name) - if err := listValue("INTENTCI_REQUIREMENTS_PATHS", &c.Requirements.Paths); err != nil { - return err - } - stringValue("INTENTCI_VERIFICATION_DEFAULT_TIMEOUT", &c.Verification.DefaultTimeout) - if err := intValue("INTENTCI_VERIFICATION_MAX_PARALLEL", &c.Verification.MaxParallel); err != nil { - return err - } - if err := boolValue("INTENTCI_VERIFICATION_FAIL_FAST", &c.Verification.FailFast); err != nil { - return err - } - stringValue("INTENTCI_VERIFICATION_WORKING_DIRECTORY", &c.Verification.WorkingDirectory) - if err := boolValue("INTENTCI_VERIFICATION_REQUIRE_CLEAN_WORKTREE", &c.Verification.RequireCleanWorktree); err != nil { - return err - } - stringValue("INTENTCI_CHANGE_IMPACT_BASE_REF", &c.ChangeImpact.BaseRef) - for _, item := range []struct { - name string - dst *bool - }{ - {"INTENTCI_CHANGE_IMPACT_INCLUDE_UNTRACKED", &c.ChangeImpact.IncludeUntracked}, - {"INTENTCI_CHANGE_IMPACT_RUN_UNMAPPED_REQUIREMENTS", &c.ChangeImpact.RunUnmappedRequirements}, - {"INTENTCI_CHANGE_IMPACT_FAIL_ON_UNMAPPED", &c.ChangeImpact.FailOnUnmapped}, - } { - if err := boolValue(item.name, item.dst); err != nil { - return err + if len(check.Paths) == 0 { + return fmt.Errorf("%s.paths must not be empty", label) } - } - if err := listValue("INTENTCI_CHANGE_IMPACT_GLOBAL_PATHS", &c.ChangeImpact.GlobalPaths); err != nil { - return err - } - stringValue("INTENTCI_EVIDENCE_DIRECTORY", &c.Evidence.Directory) - if err := boolValue("INTENTCI_EVIDENCE_RETAIN_STDOUT", &c.Evidence.RetainStdout); err != nil { - return err - } - if err := boolValue("INTENTCI_EVIDENCE_RETAIN_STDERR", &c.Evidence.RetainStderr); err != nil { - return err - } - stringValue("INTENTCI_EVIDENCE_HASH_ALGORITHM", &c.Evidence.HashAlgorithm) - if err := listValue("INTENTCI_EVIDENCE_REDACT_ENVIRONMENT", &c.Evidence.Redact.Environment); err != nil { - return err - } - if err := intValue("INTENTCI_REPAIR_MAX_ATTEMPTS", &c.Repair.MaxAttempts); err != nil { - return err - } - for _, item := range []struct { - name string - dst *bool - }{ - {"INTENTCI_REPAIR_STOP_ON_REPEATED_DIFF", &c.Repair.StopOnRepeatedDiff}, - {"INTENTCI_REPAIR_STOP_ON_REPEATED_FAILURE", &c.Repair.StopOnRepeatedFailure}, - {"INTENTCI_REPAIR_ALLOW_REQUIREMENT_CHANGES", &c.Repair.AllowRequirementChanges}, - {"INTENTCI_REPAIR_ALLOW_TEST_CHANGES", &c.Repair.AllowTestChanges}, - } { - if err := boolValue(item.name, item.dst); err != nil { - return err + for _, pattern := range check.Paths { + if err := validatePath(pattern); err != nil { + return fmt.Errorf("%s.paths: %w", label, err) + } } } - if err := listValue("INTENTCI_REPAIR_PROTECTED_PATHS", &c.Repair.ProtectedPaths); err != nil { - return err - } - if err := listValue("INTENTCI_CI_FAIL_ON", &c.CI.FailOn); err != nil { - return err - } - return boolValue("INTENTCI_TELEMETRY_ENABLED", &c.Telemetry.Enabled) + return nil } -// ParseDuration parses a Go duration or returns an error. -func ParseDuration(s string) (time.Duration, error) { - if s == "" { - return 10 * time.Minute, nil - } - d, err := time.ParseDuration(s) - if err != nil { - return 0, err +func validatePath(pattern string) error { + if pattern == "" || strings.Contains(pattern, `\`) || strings.HasPrefix(pattern, "/") { + return fmt.Errorf("invalid repository-relative pattern %q", pattern) } - if d <= 0 { - return 0, fmt.Errorf("duration must be positive") - } - return d, nil -} - -// MaxParallelOr returns MaxParallel or a fallback. -func (c *Config) MaxParallelOr(fallback int) int { - if c.Verification.MaxParallel > 0 { - return c.Verification.MaxParallel + clean := path.Clean(pattern) + if clean != pattern || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return fmt.Errorf("invalid repository-relative pattern %q", pattern) } - if fallback > 0 { - return fallback + for _, part := range strings.Split(pattern, "/") { + if part == ".." { + return fmt.Errorf("invalid repository-relative pattern %q", pattern) + } } - return 4 -} - -// BaseRefOr returns the configured base ref or default. -func (c *Config) BaseRefOr(def string) string { - if c.ChangeImpact.BaseRef != "" { - return c.ChangeImpact.BaseRef + if !doublestar.ValidatePattern(pattern) { + return fmt.Errorf("invalid glob %q", pattern) } - return def + return nil } diff --git a/internal/config/config_coverage_test.go b/internal/config/config_coverage_test.go deleted file mode 100644 index 0deee6e..0000000 --- a/internal/config/config_coverage_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package config_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/config" -) - -func TestLoadParseAndLocalErrors(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, config.DirName) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, config.ConfigFileName), []byte(":\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := config.Load(root); err == nil { - t.Fatal("parse error") - } - - if err := os.WriteFile(filepath.Join(dir, config.ConfigFileName), []byte(`version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, config.LocalFileName), []byte(":\n"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := config.Load(root); err == nil { - t.Fatal("local parse error") - } -} - -func TestValidateRemainingAndHelpers(t *testing.T) { - cfg := config.Default() - cfg.Requirements.Paths = nil - if err := cfg.Validate(); err == nil { - t.Fatal("paths") - } - cfg = config.Default() - cfg.Verification.MaxParallel = -1 - if err := cfg.Validate(); err == nil { - t.Fatal("parallel") - } - cfg = config.Default() - cfg.Evidence.Directory = "" - if err := cfg.Validate(); err == nil { - t.Fatal("evidence") - } - cfg = config.Default() - cfg.Repair.MaxAttempts = 0 - if err := cfg.Validate(); err == nil { - t.Fatal("repair") - } - - cfg = config.Default() - cfg.Verification.MaxParallel = 0 - if cfg.MaxParallelOr(7) != 7 { - t.Fatal(cfg.MaxParallelOr(7)) - } - if cfg.MaxParallelOr(0) != 4 { - t.Fatal(cfg.MaxParallelOr(0)) - } - cfg.ChangeImpact.BaseRef = "" - if cfg.BaseRefOr("fallback") != "fallback" { - t.Fatal(cfg.BaseRefOr("fallback")) - } - if config.Dir("r") == "" || config.Path("r") == "" { - t.Fatal("paths") - } -} - -func TestLoadLocalReadError(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, config.DirName) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, config.ConfigFileName), []byte(`version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -`), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(dir, config.LocalFileName), 0o755); err != nil { - t.Fatal(err) - } - if _, err := config.Load(root); err == nil { - t.Fatal("expected local read error") - } -} - -func TestLoadValidateAfterParse(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, config.DirName) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, config.ConfigFileName), []byte(`version: 2 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -`), 0o644); err != nil { - t.Fatal(err) - } - if _, err := config.Load(root); err == nil { - t.Fatal("validate") - } -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fa76c94..dbd3983 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,91 +1,82 @@ -package config_test +package config import ( "os" "path/filepath" + "strings" "testing" - - "github.com/hypertrial/intentci/internal/config" ) -func TestDefaultAndValidate(t *testing.T) { - cfg := config.Default() - if err := cfg.Validate(); err != nil { - t.Fatal(err) - } - if cfg.Telemetry.Enabled { - t.Fatal("telemetry must default false") - } - if cfg.MaxParallelOr(0) != 4 { - t.Fatalf("parallel=%d", cfg.MaxParallelOr(0)) - } - if cfg.BaseRefOr("x") != "origin/main" { - t.Fatalf("base=%s", cfg.BaseRefOr("x")) - } +func validConfig() Config { + return Config{Version: 2, Checks: []Check{{ + ID: "go-tests", Intent: "Go tests pass.", Paths: []string{"**/*.go"}, Run: "go test ./...", + }}} } -func TestLoadAndLocalOverride(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, config.DirName) - if err := os.MkdirAll(filepath.Join(dir, "requirements"), 0o755); err != nil { - t.Fatal(err) - } - body := `version: 1 -project: {name: demo} -requirements: - paths: [".intentci/requirements/**/*.md"] -verification: - default_timeout: 1m - max_parallel: 2 -` - if err := os.WriteFile(filepath.Join(dir, config.ConfigFileName), []byte(body), 0o644); err != nil { - t.Fatal(err) +func TestValidate(t *testing.T) { + tests := []struct { + name string + change func(*Config) + want string + }{ + {"version", func(c *Config) { c.Version = 1 }, "version must be 2"}, + {"empty checks", func(c *Config) { c.Checks = nil }, "checks must not be empty"}, + {"invalid id", func(c *Config) { c.Checks[0].ID = "Go Test" }, "must match"}, + {"duplicate id", func(c *Config) { c.Checks = append(c.Checks, c.Checks[0]) }, "duplicate"}, + {"empty intent", func(c *Config) { c.Checks[0].Intent = " " }, "intent"}, + {"empty run", func(c *Config) { c.Checks[0].Run = "" }, "run"}, + {"empty paths", func(c *Config) { c.Checks[0].Paths = nil }, "paths"}, + {"absolute path", func(c *Config) { c.Checks[0].Paths = []string{"/tmp/**"} }, "invalid"}, + {"traversal", func(c *Config) { c.Checks[0].Paths = []string{"../**"} }, "invalid"}, + {"backslash", func(c *Config) { c.Checks[0].Paths = []string{`src\**`} }, "invalid"}, + {"unclean path", func(c *Config) { c.Checks[0].Paths = []string{"src//**"} }, "invalid"}, + {"invalid glob", func(c *Config) { c.Checks[0].Paths = []string{"["} }, "invalid glob"}, } - local := `verification: - max_parallel: 8 -` - if err := os.WriteFile(filepath.Join(dir, config.LocalFileName), []byte(local), 0o644); err != nil { - t.Fatal(err) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := validConfig() + test.change(&cfg) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Validate() error = %v, want %q", err, test.want) + } + }) } - cfg, err := config.Load(root) - if err != nil { + if err := validConfig().Validate(); err != nil { t.Fatal(err) } - if cfg.Project.Name != "demo" || cfg.Verification.MaxParallel != 8 { - t.Fatalf("%+v", cfg) - } } -func TestValidateErrors(t *testing.T) { - cfg := config.Default() - cfg.Version = 2 - if err := cfg.Validate(); err == nil { - t.Fatal("expected version error") +func TestLoadStrictYAML(t *testing.T) { + root := t.TempDir() + write := func(content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, FileName), []byte(content), 0o644); err != nil { + t.Fatal(err) + } } - cfg = config.Default() - cfg.Project.Name = "" - if err := cfg.Validate(); err == nil { - t.Fatal("expected name error") + write(`version: 2 +checks: + - id: tests + intent: Tests pass. + paths: ["**"] + run: | + echo first + echo second +`) + cfg, err := Load(root) + if err != nil { + t.Fatal(err) } - cfg = config.Default() - cfg.Verification.DefaultTimeout = "nope" - if err := cfg.Validate(); err == nil { - t.Fatal("expected timeout error") + if !strings.Contains(cfg.Checks[0].Run, "echo second") { + t.Fatalf("multiline command lost: %q", cfg.Checks[0].Run) } -} -func TestParseDuration(t *testing.T) { - d, err := config.ParseDuration("") - if err != nil || d.Minutes() != 10 { - t.Fatalf("%v %v", d, err) - } - if _, err := config.ParseDuration("0s"); err == nil { - t.Fatal("expected error") + write("version: 2\nchecks: []\nunknown: true\n") + if _, err := Load(root); err == nil || !strings.Contains(err.Error(), "field unknown") { + t.Fatalf("unknown field error = %v", err) } -} - -func TestLoadMissing(t *testing.T) { - if _, err := config.Load(t.TempDir()); err == nil { - t.Fatal("expected error") + write("version: 2\nchecks: []\n---\nversion: 2\n") + if _, err := Load(root); err == nil || !strings.Contains(err.Error(), "multiple YAML") { + t.Fatalf("multiple document error = %v", err) } } diff --git a/internal/config/config_v1_test.go b/internal/config/config_v1_test.go deleted file mode 100644 index 7281a12..0000000 --- a/internal/config/config_v1_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package config_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/config" -) - -func writeConfig(t *testing.T, root string) { - t.Helper() - if err := os.MkdirAll(filepath.Join(root, ".intentci"), 0o755); err != nil { - t.Fatal(err) - } - body := `version: 1 -project: {name: primary} -requirements: - paths: [".intentci/requirements/**/*.md"] -` - if err := os.WriteFile(filepath.Join(root, ".intentci", "config.yaml"), []byte(body), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestExplicitEnvironmentOverridesEveryConfigSection(t *testing.T) { - root := t.TempDir() - writeConfig(t, root) - overrides := map[string]string{ - "INTENTCI_VERSION": "1", - "INTENTCI_PROJECT_NAME": "environment", - "INTENTCI_REQUIREMENTS_PATHS": `["contracts/*.md"]`, - "INTENTCI_VERIFICATION_DEFAULT_TIMEOUT": "3.5s", - "INTENTCI_VERIFICATION_MAX_PARALLEL": "9", - "INTENTCI_VERIFICATION_FAIL_FAST": "true", - "INTENTCI_VERIFICATION_WORKING_DIRECTORY": "src", - "INTENTCI_VERIFICATION_REQUIRE_CLEAN_WORKTREE": "true", - "INTENTCI_CHANGE_IMPACT_BASE_REF": "main", - "INTENTCI_CHANGE_IMPACT_INCLUDE_UNTRACKED": "false", - "INTENTCI_CHANGE_IMPACT_RUN_UNMAPPED_REQUIREMENTS": "true", - "INTENTCI_CHANGE_IMPACT_FAIL_ON_UNMAPPED": "true", - "INTENTCI_CHANGE_IMPACT_GLOBAL_PATHS": `["global/**"]`, - "INTENTCI_EVIDENCE_DIRECTORY": "evidence", - "INTENTCI_EVIDENCE_RETAIN_STDOUT": "false", - "INTENTCI_EVIDENCE_RETAIN_STDERR": "false", - "INTENTCI_EVIDENCE_HASH_ALGORITHM": "sha256", - "INTENTCI_EVIDENCE_REDACT_ENVIRONMENT": `["*PRIVATE*"]`, - "INTENTCI_REPAIR_MAX_ATTEMPTS": "4", - "INTENTCI_REPAIR_STOP_ON_REPEATED_DIFF": "false", - "INTENTCI_REPAIR_STOP_ON_REPEATED_FAILURE": "false", - "INTENTCI_REPAIR_ALLOW_REQUIREMENT_CHANGES": "true", - "INTENTCI_REPAIR_ALLOW_TEST_CHANGES": "false", - "INTENTCI_REPAIR_PROTECTED_PATHS": `["protected/**"]`, - "INTENTCI_CI_FAIL_ON": `["fail","error"]`, - "INTENTCI_TELEMETRY_ENABLED": "true", - } - for name, value := range overrides { - t.Setenv(name, value) - } - if err := os.Mkdir(filepath.Join(root, "src"), 0o755); err != nil { - t.Fatal(err) - } - cfg, err := config.Load(root) - if err != nil { - t.Fatal(err) - } - if cfg.Project.Name != "environment" || cfg.Verification.MaxParallel != 9 || - !cfg.Verification.FailFast || cfg.ChangeImpact.IncludeUntracked || - !cfg.ChangeImpact.FailOnUnmapped || cfg.Evidence.RetainStdout || - cfg.Repair.MaxAttempts != 4 || !cfg.Repair.AllowRequirementChanges || - len(cfg.CI.FailOn) != 2 || !cfg.Telemetry.Enabled { - t.Fatalf("%+v", cfg) - } -} - -func TestEnvironmentAndValidationFailures(t *testing.T) { - for _, testCase := range []struct { - name string - value string - }{ - {"INTENTCI_VERSION", "bad"}, - {"INTENTCI_VERIFICATION_MAX_PARALLEL", "bad"}, - {"INTENTCI_VERIFICATION_FAIL_FAST", "bad"}, - {"INTENTCI_VERIFICATION_REQUIRE_CLEAN_WORKTREE", "bad"}, - {"INTENTCI_CHANGE_IMPACT_INCLUDE_UNTRACKED", "bad"}, - {"INTENTCI_CHANGE_IMPACT_RUN_UNMAPPED_REQUIREMENTS", "bad"}, - {"INTENTCI_CHANGE_IMPACT_FAIL_ON_UNMAPPED", "bad"}, - {"INTENTCI_CHANGE_IMPACT_GLOBAL_PATHS", "bad"}, - {"INTENTCI_EVIDENCE_RETAIN_STDOUT", "bad"}, - {"INTENTCI_EVIDENCE_RETAIN_STDERR", "bad"}, - {"INTENTCI_EVIDENCE_REDACT_ENVIRONMENT", "bad"}, - {"INTENTCI_REPAIR_MAX_ATTEMPTS", "bad"}, - {"INTENTCI_REPAIR_STOP_ON_REPEATED_DIFF", "bad"}, - {"INTENTCI_REPAIR_PROTECTED_PATHS", "bad"}, - {"INTENTCI_CI_FAIL_ON", "bad"}, - {"INTENTCI_REQUIREMENTS_PATHS", "bad"}, - } { - t.Run(testCase.name, func(t *testing.T) { - root := t.TempDir() - writeConfig(t, root) - t.Setenv(testCase.name, testCase.value) - if _, err := config.Load(root); err == nil { - t.Fatal("invalid environment accepted") - } - }) - } - mutations := []func(*config.Config){ - func(cfg *config.Config) { cfg.Requirements.Paths = []string{"../outside"} }, - func(cfg *config.Config) { cfg.ChangeImpact.GlobalPaths = []string{"["} }, - func(cfg *config.Config) { cfg.Evidence.Redact.Environment = []string{"["} }, - func(cfg *config.Config) { cfg.Verification.WorkingDirectory = "/absolute" }, - func(cfg *config.Config) { cfg.Evidence.Directory = "../outside" }, - func(cfg *config.Config) { cfg.Evidence.HashAlgorithm = "md5" }, - func(cfg *config.Config) { cfg.CI.FailOn = []string{"invalid"} }, - } - for index, mutate := range mutations { - cfg := config.Default() - mutate(cfg) - if err := cfg.Validate(); err == nil { - t.Fatalf("mutation %d accepted: %+v", index, cfg) - } - } -} diff --git a/internal/evidence/bundle.go b/internal/evidence/bundle.go deleted file mode 100644 index 178da9a..0000000 --- a/internal/evidence/bundle.go +++ /dev/null @@ -1,640 +0,0 @@ -package evidence - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/oklog/ulid/v2" - - repogit "github.com/hypertrial/intentci/internal/git" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/security" - "github.com/hypertrial/intentci/internal/verdict" - appschema "github.com/hypertrial/intentci/pkg/schema" -) - -// Bundle is a persisted verification run. -type Bundle struct { - RunID string `json:"run_id"` - AttemptID string `json:"attempt_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - Root string `json:"root"` - BaseCommit string `json:"base_commit,omitempty"` - HeadCommit string `json:"head_commit,omitempty"` - ConfigHash string `json:"config_hash,omitempty"` - IRHash string `json:"ir_hash,omitempty"` - ManifestHash string `json:"manifest_hash,omitempty"` - Interrupted bool `json:"interrupted,omitempty"` - Document *ir.Document `json:"document,omitempty"` - VerificationPlan *ir.VerificationPlan `json:"verification_plan,omitempty"` - Run verdict.RunResult `json:"run"` - ProviderLogs map[string]provider.Result `json:"provider_results,omitempty"` - RepositoryState *repogit.State `json:"repository_state,omitempty"` - Unmapped []string `json:"unmapped_files,omitempty"` -} - -// Manifest records the immutable artifacts belonging to a run. -type Manifest struct { - SchemaVersion string `json:"schema_version"` - RunID string `json:"run_id"` - HashAlgorithm string `json:"hash_algorithm"` - Artifacts []ManifestArtifact `json:"artifacts"` -} - -// ManifestArtifact is a content-addressed run artifact. -type ManifestArtifact struct { - Path string `json:"path"` - SHA256 string `json:"sha256"` -} - -// FinalVerdict closes a run without introducing a manifest hash cycle. -type FinalVerdict struct { - SchemaVersion string `json:"schema_version"` - RunID string `json:"run_id"` - ManifestHash string `json:"manifest_hash"` - Run verdict.RunResult `json:"run"` - Interrupted bool `json:"interrupted,omitempty"` -} - -// Store writes and loads evidence bundles. -type Store struct { - Root string // absolute configured evidence directory - RedactPatterns []string - repoRoot string - relativeRoot string -} - -var mkdirAll = os.MkdirAll -var writeFile = os.WriteFile -var renameFile = os.Rename -var removeFile = os.Remove -var readFile = os.ReadFile -var statFile = os.Stat -var absolutePath = filepath.Abs -var evaluateSymlinks = filepath.EvalSymlinks -var relativePath = filepath.Rel - -// NewStore creates a store under evidence directory relative to repo root. -func NewStore(repoRoot, evidenceDir string) (*Store, error) { - if evidenceDir == "" { - return nil, fmt.Errorf("evidence directory is required") - } - dir := evidenceDir - relativeRoot := "" - if !filepath.IsAbs(dir) { - relativeRoot = filepath.Clean(evidenceDir) - resolved, err := security.ResolveInside(repoRoot, evidenceDir) - if err != nil { - return nil, err - } - if err := mkdirAll(resolved, 0o755); err != nil { - return nil, err - } - dir = filepath.Join(repoRoot, evidenceDir) - } - if err := mkdirAll(dir, 0o755); err != nil { - return nil, err - } - absolute, err := absolutePath(dir) - if err != nil { - return nil, err - } - if _, err := evaluateSymlinks(absolute); err != nil { - return nil, err - } - return &Store{Root: absolute, repoRoot: repoRoot, relativeRoot: relativeRoot}, nil -} - -// NewRunID returns a new ULID run id. -func NewRunID() string { - return ulid.Make().String() -} - -// Dir returns the directory for a run. -func (s *Store) Dir(runID string) string { - return filepath.Join(s.Root, runID) -} - -// WriteBundle persists a complete single-attempt run. -func (s *Store) WriteBundle(bundle *Bundle) error { - if err := s.WriteAttempt(bundle); err != nil { - return err - } - return s.Finalize(bundle) -} - -// WriteAttempt writes immutable inputs, evidence, logs, and the attempt verdict. -func (s *Store) WriteAttempt(bundle *Bundle) error { - if err := validateRunID(bundle.RunID); err != nil { - return err - } - if err := s.ensureOpen(bundle.RunID); err != nil { - return err - } - if bundle.AttemptID == "" { - bundle.AttemptID = "attempt-001" - } - if err := validateAttemptID(bundle.AttemptID); err != nil { - return err - } - if bundle.CreatedAt.IsZero() { - bundle.CreatedAt = time.Now().UTC() - } - if bundle.Run.Requirements == nil { - bundle.Run.Requirements = []verdict.RequirementResult{} - } - if err := validateVerdicts(bundle.Run); err != nil { - return err - } - runDir := s.Dir(bundle.RunID) - if _, err := s.safePath(runDir); err != nil { - return err - } - if err := mkdirAll(runDir, 0o755); err != nil { - return err - } - attemptDir := filepath.Join(runDir, "attempts", bundle.AttemptID) - if err := mkdirAll(filepath.Join(attemptDir, "logs"), 0o755); err != nil { - return err - } - if err := mkdirAll(filepath.Join(attemptDir, "artifacts"), 0o755); err != nil { - return err - } - if bundle.Document != nil { - if bundle.Document.Requirements == nil { - bundle.Document.Requirements = make([]ir.Requirement, 0) - } - if bundle.Document.Hash == "" { - if err := bundle.Document.ComputeHashes(); err != nil { - return err - } - bundle.IRHash = bundle.Document.Hash - } - if err := appschema.Validate("ir", bundle.Document); err != nil { - return err - } - if err := s.writeJSONImmutable(filepath.Join(runDir, "compiled-intent.json"), bundle.Document); err != nil { - return err - } - if bundle.VerificationPlan == nil { - plan, _ := ir.BuildVerificationPlan(bundle.Document, bundle.Document.Requirements) - bundle.VerificationPlan = plan - } - } - if bundle.VerificationPlan != nil { - if err := appschema.Validate("plan", bundle.VerificationPlan); err != nil { - return err - } - if err := s.writeJSONImmutable(filepath.Join(runDir, "verification-plan.json"), bundle.VerificationPlan); err != nil { - return err - } - } - if bundle.RepositoryState != nil { - if err := s.writeInitialJSON(filepath.Join(runDir, "repository-state.json"), bundle.RepositoryState); err != nil { - return err - } - if err := s.writeJSONImmutable(filepath.Join(attemptDir, "repository-state.json"), bundle.RepositoryState); err != nil { - return err - } - } - patch := "" - if bundle.RepositoryState != nil { - patch = bundle.RepositoryState.DiffPatch - } - if err := s.writeInitialImmutable(filepath.Join(runDir, "diff.patch"), []byte(patch)); err != nil { - return err - } - if err := s.writeImmutable(filepath.Join(attemptDir, "diff.patch"), []byte(patch)); err != nil { - return err - } - - var records []provider.Evidence - keys := sortedProviderKeys(bundle.ProviderLogs) - for _, key := range keys { - result := bundle.ProviderLogs[key] - for _, record := range result.Evidence { - if err := appschema.Validate("evidence", record); err != nil { - return fmt.Errorf("%s: %w", key, err) - } - records = append(records, record) - } - name := safeName(key) - if result.Stdout != "" { - if err := s.writeImmutable(filepath.Join(attemptDir, "logs", name+".stdout"), []byte(result.Stdout)); err != nil { - return err - } - } - if result.Stderr != "" { - if err := s.writeImmutable(filepath.Join(attemptDir, "logs", name+".stderr"), []byte(result.Stderr)); err != nil { - return err - } - } - } - if err := s.writeJSONImmutable(filepath.Join(attemptDir, "evidence.json"), records); err != nil { - return err - } - if err := s.writeJSONImmutable(filepath.Join(attemptDir, "verdict.json"), bundle.Run); err != nil { - return err - } - raw, err := json.MarshalIndent(bundle, "", " ") - if err != nil { - return err - } - if err := s.writeAtomic(filepath.Join(runDir, "result.json"), append(raw, '\n')); err != nil { - return err - } - return s.writeAtomic(filepath.Join(s.Root, "latest"), []byte(bundle.RunID+"\n")) -} - -func validateVerdicts(run verdict.RunResult) error { - if err := appschema.Validate("verdict", run.Verdict); err != nil { - return err - } - for _, requirement := range run.Requirements { - if err := appschema.Validate("verdict", requirement.Verdict); err != nil { - return fmt.Errorf("%s: %w", requirement.ID, err) - } - for _, obligation := range requirement.Obligations { - if err := appschema.Validate("verdict", obligation.Verdict); err != nil { - return fmt.Errorf("%s/%s: %w", requirement.ID, obligation.ID, err) - } - } - } - return nil -} - -// WriteReport writes a generated report before manifest finalization. -func (s *Store) WriteReport(runID, name string, content []byte) error { - if err := validateRunID(runID); err != nil { - return err - } - if err := s.ensureOpen(runID); err != nil { - return err - } - switch name { - case "report.txt", "report.json", "report.junit.xml": - default: - return fmt.Errorf("unsupported report path %q", name) - } - return s.writeImmutable(filepath.Join(s.Dir(runID), name), content) -} - -// Finalize hashes all immutable files and writes the manifest and final verdict. -func (s *Store) Finalize(bundle *Bundle) error { - if err := validateRunID(bundle.RunID); err != nil { - return err - } - runDir := s.Dir(bundle.RunID) - safeRunDir, err := s.safePath(runDir) - if err != nil { - return err - } - artifacts, err := hashArtifacts(safeRunDir) - if err != nil { - return err - } - manifest := Manifest{ - SchemaVersion: "1.0", RunID: bundle.RunID, HashAlgorithm: "sha256", Artifacts: artifacts, - } - raw, _ := json.MarshalIndent(manifest, "", " ") - raw = append(raw, '\n') - if err := s.writeImmutable(filepath.Join(runDir, "manifest.json"), raw); err != nil { - return err - } - sum := sha256.Sum256(s.redact(raw)) - bundle.ManifestHash = hex.EncodeToString(sum[:]) - final := FinalVerdict{ - SchemaVersion: "1.0", RunID: bundle.RunID, ManifestHash: bundle.ManifestHash, - Run: bundle.Run, Interrupted: bundle.Interrupted, - } - return s.writeImmutableJSON(filepath.Join(runDir, "final-verdict.json"), final) -} - -// LoadLatest loads the latest bundle if present. -func (s *Store) LoadLatest() (*Bundle, error) { - path, err := s.safePath(filepath.Join(s.Root, "latest")) - if err != nil { - return nil, err - } - data, err := readFile(path) - if err != nil { - return nil, err - } - return s.Load(string(bytesTrim(data))) -} - -// Load loads a bundle by run id. -func (s *Store) Load(runID string) (*Bundle, error) { - if err := validateRunID(runID); err != nil { - return nil, err - } - path, err := s.safePath(filepath.Join(s.Dir(runID), "result.json")) - if err != nil { - return nil, err - } - data, err := readFile(path) - if err != nil { - return nil, err - } - var bundle Bundle - if err := json.Unmarshal(data, &bundle); err != nil { - return nil, fmt.Errorf("parse result: %w", err) - } - return &bundle, nil -} - -func bytesTrim(value []byte) []byte { - return []byte(strings.TrimSpace(string(value))) -} - -// WriteRepairPacket writes a compatibility packet at the run root. -func (s *Store) WriteRepairPacket(runID string, packet any) error { - if err := validateRunID(runID); err != nil { - return err - } - if err := s.ensureOpen(runID); err != nil { - return err - } - return s.writeJSONAtomic(filepath.Join(s.Dir(runID), "repair-packet.json"), packet) -} - -// WriteRepairPacketForAttempt writes an immutable packet under an attempt. -func (s *Store) WriteRepairPacketForAttempt(runID, attemptID string, packet any) (string, error) { - if err := validateRunID(runID); err != nil { - return "", err - } - if err := validateAttemptID(attemptID); err != nil { - return "", err - } - if err := s.ensureOpen(runID); err != nil { - return "", err - } - if err := appschema.Validate("repair", packet); err != nil { - return "", err - } - path := filepath.Join(s.Dir(runID), "attempts", attemptID, "repair-packet.json") - if err := s.writeImmutableJSON(path, packet); err != nil { - return "", err - } - return path, nil -} - -// WriteAgentLog writes a redacted immutable agent stream. -func (s *Store) WriteAgentLog(runID, attemptID, stream string, content []byte) error { - if err := validateRunID(runID); err != nil { - return err - } - if err := validateAttemptID(attemptID); err != nil { - return err - } - if err := s.ensureOpen(runID); err != nil { - return err - } - if stream != "stdout" && stream != "stderr" { - return fmt.Errorf("invalid agent stream %q", stream) - } - path := filepath.Join(s.Dir(runID), "attempts", attemptID, "logs", "agent."+stream) - return s.writeImmutable(path, content) -} - -// WriteRepairArtifact writes one of the repair controller's immutable records. -func (s *Store) WriteRepairArtifact(runID, attemptID, name string, content []byte) error { - if err := validateRunID(runID); err != nil { - return err - } - if err := validateAttemptID(attemptID); err != nil { - return err - } - if err := s.ensureOpen(runID); err != nil { - return err - } - switch name { - case "patch-before.diff", "patch-after.diff", "agent-exit.json": - default: - return fmt.Errorf("invalid repair artifact %q", name) - } - path := filepath.Join(s.Dir(runID), "attempts", attemptID, name) - return s.writeImmutable(path, content) -} - -func (s *Store) ensureOpen(runID string) error { - path, err := s.safePath(filepath.Join(s.Dir(runID), "manifest.json")) - if err != nil { - return err - } - if _, err := statFile(path); err == nil { - return fmt.Errorf("run %s is finalized and immutable", runID) - } else if !os.IsNotExist(err) { - return err - } - return nil -} - -func (s *Store) writeJSONImmutable(path string, value any) error { - raw, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - return s.writeImmutable(path, append(raw, '\n')) -} - -func (s *Store) writeImmutableJSON(path string, value any) error { - return s.writeJSONImmutable(path, value) -} - -func (s *Store) writeJSONAtomic(path string, value any) error { - raw, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - return s.writeAtomic(path, append(raw, '\n')) -} - -func (s *Store) writeInitialJSON(path string, value any) error { - raw, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - return s.writeInitialImmutable(path, append(raw, '\n')) -} - -func (s *Store) writeInitialImmutable(path string, content []byte) error { - safe, err := s.safePath(path) - if err != nil { - return err - } - if _, err := statFile(safe); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err - } - return s.writeImmutable(path, content) -} - -func (s *Store) writeImmutable(path string, content []byte) error { - original := path - safe, err := s.safePath(path) - if err != nil { - return err - } - path = safe - redacted := s.redact(content) - if existing, err := readFile(path); err == nil { - if string(existing) == string(redacted) { - return nil - } - return fmt.Errorf("immutable artifact already exists: %s", path) - } else if !os.IsNotExist(err) { - return err - } - return s.writeAtomic(original, redacted) -} - -func (s *Store) writeAtomic(path string, content []byte) error { - safe, err := s.safePath(path) - if err != nil { - return err - } - path = safe - if err := mkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - temporary := filepath.Join(filepath.Dir(path), "."+filepath.Base(path)+".tmp-"+ulid.Make().String()) - if err := writeFile(temporary, s.redact(content), 0o644); err != nil { - return err - } - defer removeFile(temporary) - return renameFile(temporary, path) -} - -func (s *Store) safePath(path string) (string, error) { - relative, err := relativePath(s.Root, path) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("evidence path escapes configured directory: %s", path) - } - if s.relativeRoot == "" { - root, err := evaluateSymlinks(s.Root) - if err != nil { - return "", err - } - return security.ResolveInside(root, relative) - } - root, err := security.ResolveInside(s.repoRoot, s.relativeRoot) - if err != nil { - return "", err - } - return security.ResolveInside(root, relative) -} - -func (s *Store) redact(content []byte) []byte { - redactor := security.NewRedactor(s.RedactPatterns, os.Environ()) - if json.Valid(content) { - decoder := json.NewDecoder(strings.NewReader(string(content))) - decoder.UseNumber() - var value any - _ = decoder.Decode(&value) - value = redactJSONValue(value, redactor) - raw, _ := json.MarshalIndent(value, "", " ") - return append(raw, '\n') - } - return []byte(redactor.Redact(string(content))) -} - -func redactJSONValue(value any, redactor security.Redactor) any { - switch typed := value.(type) { - case string: - return redactor.Redact(typed) - case []any: - for index := range typed { - typed[index] = redactJSONValue(typed[index], redactor) - } - case map[string]any: - for key := range typed { - typed[key] = redactJSONValue(typed[key], redactor) - } - } - return value -} - -func validateRunID(value string) error { - if value == "" || value == "." || value == ".." || strings.ContainsAny(value, `/\`) { - return fmt.Errorf("invalid run id %q", value) - } - return nil -} - -func validateAttemptID(value string) error { - if value == "" || value == "." || value == ".." || strings.ContainsAny(value, `/\`) { - return fmt.Errorf("invalid attempt id %q", value) - } - return nil -} - -func sortedProviderKeys(results map[string]provider.Result) []string { - keys := make([]string, 0, len(results)) - for key := range results { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -func safeName(value string) string { - var builder strings.Builder - for _, character := range value { - if (character >= 'a' && character <= 'z') || - (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || - character == '-' || character == '_' || character == '.' { - builder.WriteRune(character) - } else { - builder.WriteByte('_') - } - } - if builder.Len() == 0 { - return "provider" - } - return builder.String() -} - -func hashArtifacts(root string) ([]ManifestArtifact, error) { - var artifacts []ManifestArtifact - err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if path == root { - return nil - } - relative, _ := filepath.Rel(root, path) - relative = filepath.ToSlash(relative) - if entry.Type()&os.ModeSymlink != 0 { - return fmt.Errorf("evidence artifact may not be a symlink: %s", relative) - } - if entry.IsDir() { - return nil - } - if relative == "manifest.json" || relative == "final-verdict.json" || strings.Contains(relative, ".tmp-") { - return nil - } - content, err := readFile(path) - if err != nil { - return err - } - sum := sha256.Sum256(content) - artifacts = append(artifacts, ManifestArtifact{ - Path: relative, SHA256: hex.EncodeToString(sum[:]), - }) - return nil - }) - sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Path < artifacts[j].Path }) - return artifacts, err -} diff --git a/internal/evidence/bundle_coverage_test.go b/internal/evidence/bundle_coverage_test.go deleted file mode 100644 index 5440e47..0000000 --- a/internal/evidence/bundle_coverage_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package evidence_test - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestNewStoreAbsAndErrors(t *testing.T) { - abs := filepath.Join(t.TempDir(), "absruns") - store, err := evidence.NewStore(t.TempDir(), abs) - if err != nil || store.Root != abs { - t.Fatalf("%v %+v", err, store) - } - // mkdir fail: evidenceDir is an existing file - root := t.TempDir() - file := filepath.Join(root, "runs") - if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := evidence.NewStore(root, "runs"); err == nil { - t.Fatal("expected mkdir error") - } -} - -func TestWriteLoadErrors(t *testing.T) { - store, err := evidence.NewStore(t.TempDir(), "runs") - if err != nil { - t.Fatal(err) - } - if _, err := store.LoadLatest(); err == nil { - t.Fatal("expected missing latest") - } - if _, err := store.Load("nope"); err == nil { - t.Fatal("expected missing") - } - - // WriteBundle when run dir path is a file - if err := os.WriteFile(filepath.Join(store.Root, "bad"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := store.WriteBundle(&evidence.Bundle{RunID: "bad", Run: verdict.RunResult{Verdict: "pass"}}); err == nil { - t.Fatal("expected mkdir error") - } - - id := evidence.NewRunID() - b := &evidence.Bundle{ - RunID: id, CreatedAt: time.Now().UTC(), - Document: &ir.Document{SchemaVersion: 1, Project: "p"}, - Run: verdict.RunResult{Verdict: verdict.Pass}, - } - if err := store.WriteBundle(b); err != nil { - t.Fatal(err) - } - // corrupt result - if err := os.WriteFile(filepath.Join(store.Dir(id), "result.json"), []byte("{"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := store.Load(id); err == nil { - t.Fatal("expected parse error") - } - - // WriteRepairPacket mkdir fail - if err := os.WriteFile(filepath.Join(store.Root, "rp"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := store.WriteRepairPacket("rp", map[string]any{"a": 1}); err == nil { - t.Fatal("expected error") - } - // marshal fail - if err := store.WriteRepairPacket("open", make(chan int)); err == nil { - t.Fatal("expected marshal error") - } - if err := store.WriteRepairPacket("open", map[string]any{"ok": true}); err != nil { - t.Fatal(err) - } - // LoadLatest trims whitespace - if err := os.WriteFile(filepath.Join(store.Root, "latest"), []byte(id+"\r\n"), 0o644); err != nil { - t.Fatal(err) - } - // Finalized runs are immutable; write a separate replacement run. - id2 := evidence.NewRunID() - b2 := &evidence.Bundle{RunID: id2, Run: verdict.RunResult{Verdict: verdict.Pass}} - if err := store.WriteBundle(b2); err != nil { - t.Fatal(err) - } - got, err := store.LoadLatest() - if err != nil || got.RunID != id2 { - t.Fatalf("%v %+v", err, got) - } -} diff --git a/internal/evidence/bundle_internal_test.go b/internal/evidence/bundle_internal_test.go deleted file mode 100644 index 4afbb7e..0000000 --- a/internal/evidence/bundle_internal_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package evidence - -import ( - "errors" - "os" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestWriteBundleMarshalAndWriteErrors(t *testing.T) { - store, err := NewStore(t.TempDir(), "runs") - if err != nil { - t.Fatal(err) - } - badDoc := &ir.Document{SchemaVersion: 1, Project: "p", Requirements: []ir.Requirement{{ - ID: "R", Obligations: []ir.Obligation{{Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Extra: map[string]any{"c": make(chan int)}, - }}}}, - }}} - if err := store.WriteBundle(&Bundle{RunID: "d1", Document: badDoc, Run: verdict.RunResult{}}); err == nil { - t.Fatal("doc marshal") - } - - old := writeFile - defer func() { writeFile = old }() - n := 0 - writeFile = func(name string, data []byte, perm os.FileMode) error { - n++ - if n == 1 { - return errors.New("compiled write") - } - return old(name, data, perm) - } - okDoc := &ir.Document{SchemaVersion: 1, Project: "p"} - if err := store.WriteBundle(&Bundle{RunID: "d2", CreatedAt: time.Now().UTC(), Document: okDoc, Run: verdict.RunResult{}}); err == nil { - t.Fatal("compiled write") - } - - writeFile = func(name string, data []byte, perm os.FileMode) error { - return errors.New("result write") - } - if err := store.WriteBundle(&Bundle{RunID: "d3", Run: verdict.RunResult{}}); err == nil { - t.Fatal("result write") - } - - writeFile = old - b := &Bundle{ - RunID: "d4", - ProviderLogs: map[string]provider.Result{ - "x": {Extra: map[string]any{"c": make(chan int)}}, - }, - Run: verdict.RunResult{}, - } - if err := store.WriteBundle(b); err == nil { - t.Fatal("bundle marshal") - } -} diff --git a/internal/evidence/bundle_test.go b/internal/evidence/bundle_test.go deleted file mode 100644 index d9c6ab9..0000000 --- a/internal/evidence/bundle_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package evidence_test - -import ( - "testing" - "time" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestStoreRoundTrip(t *testing.T) { - store, err := evidence.NewStore(t.TempDir(), "runs") - if err != nil { - t.Fatal(err) - } - id := evidence.NewRunID() - b := &evidence.Bundle{ - RunID: id, CreatedAt: time.Now().UTC(), - Document: &ir.Document{SchemaVersion: 1, Project: "p", Requirements: nil}, - Run: verdict.RunResult{Verdict: verdict.Pass}, - } - if err := store.WriteBundle(b); err != nil { - t.Fatal(err) - } - if err := store.WriteRepairPacket(id, map[string]any{"run_id": id}); err == nil { - t.Fatal("finalized run accepted a new repair packet") - } - got, err := store.LoadLatest() - if err != nil || got.RunID != id { - t.Fatalf("%v %+v", err, got) - } -} diff --git a/internal/evidence/bundle_v1_internal_test.go b/internal/evidence/bundle_v1_internal_test.go deleted file mode 100644 index e587a60..0000000 --- a/internal/evidence/bundle_v1_internal_test.go +++ /dev/null @@ -1,498 +0,0 @@ -package evidence - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - repogit "github.com/hypertrial/intentci/internal/git" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -func validTestBundle(t *testing.T, runID string) *Bundle { - t.Helper() - document := &ir.Document{SchemaVersion: 1, Project: "project", Requirements: []ir.Requirement{}} - if err := document.ComputeHashes(); err != nil { - t.Fatal(err) - } - plan, err := ir.BuildVerificationPlan(document, nil) - if err != nil { - t.Fatal(err) - } - return &Bundle{ - RunID: runID, AttemptID: "attempt-001", CreatedAt: time.Now().UTC(), - Document: document, VerificationPlan: plan, - RepositoryState: &repogit.State{DiffPatch: "patch"}, - ProviderLogs: map[string]provider.Result{ - "provider": {Stdout: "out", Stderr: "err"}, - }, - Run: verdict.RunResult{Verdict: verdict.Pass}, - } -} - -func TestNewStoreAllFailures(t *testing.T) { - if _, err := NewStore(t.TempDir(), ""); err == nil { - t.Fatal("empty evidence directory accepted") - } - oldMkdir, oldAbs, oldEval := mkdirAll, absolutePath, evaluateSymlinks - defer func() { - mkdirAll, absolutePath, evaluateSymlinks = oldMkdir, oldAbs, oldEval - }() - root := t.TempDir() - mkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") } - if _, err := NewStore(root, "runs"); err == nil { - t.Fatal("relative mkdir failure ignored") - } - if _, err := NewStore(root, filepath.Join(root, "absolute")); err == nil { - t.Fatal("absolute mkdir failure ignored") - } - mkdirAll = oldMkdir - absolutePath = func(string) (string, error) { return "", errors.New("absolute") } - if _, err := NewStore(root, filepath.Join(root, "absolute")); err == nil { - t.Fatal("absolute path failure ignored") - } - absolutePath = oldAbs - evaluateSymlinks = func(string) (string, error) { return "", errors.New("symlinks") } - if _, err := NewStore(root, filepath.Join(root, "absolute")); err == nil { - t.Fatal("symlink evaluation failure ignored") - } -} - -func TestWriteAttemptStageFailures(t *testing.T) { - store, err := NewStore(t.TempDir(), "runs") - if err != nil { - t.Fatal(err) - } - oldWrite := writeFile - defer func() { writeFile = oldWrite }() - for failure := 1; failure <= 12; failure++ { - calls := 0 - writeFile = func(path string, content []byte, mode os.FileMode) error { - calls++ - if calls == failure { - return errors.New("stage") - } - return oldWrite(path, content, mode) - } - bundle := validTestBundle(t, "stage-"+string(rune('a'+failure))) - if err := store.WriteAttempt(bundle); err == nil { - t.Fatalf("write stage %d failure ignored", failure) - } - } - writeFile = oldWrite - - oldMkdir := mkdirAll - defer func() { mkdirAll = oldMkdir }() - for index, suffix := range []string{"run-mkdir", filepath.Join("logs"), filepath.Join("artifacts")} { - runID := "mkdir-case-" + string(rune('a'+index)) - mkdirAll = func(path string, mode os.FileMode) error { - if (suffix == "run-mkdir" && filepath.Base(path) == runID) || - (suffix != "run-mkdir" && strings.HasSuffix(path, suffix)) { - return errors.New("mkdir") - } - return oldMkdir(path, mode) - } - if err := store.WriteAttempt(&Bundle{ - RunID: runID, AttemptID: "attempt", Run: verdict.RunResult{Verdict: verdict.Pass}, - }); err == nil { - t.Fatalf("%s mkdir failure ignored", suffix) - } - } -} - -func TestWriteAttemptValidationFailures(t *testing.T) { - store, err := NewStore(t.TempDir(), "runs") - if err != nil { - t.Fatal(err) - } - for _, bundle := range []*Bundle{ - {RunID: "../run", Run: verdict.RunResult{Verdict: verdict.Pass}}, - {RunID: "run", AttemptID: "../attempt", Run: verdict.RunResult{Verdict: verdict.Pass}}, - {RunID: "bad-run", Run: verdict.RunResult{Verdict: "invalid"}}, - {RunID: "bad-requirement", Run: verdict.RunResult{Verdict: verdict.Pass, Requirements: []verdict.RequirementResult{{ID: "R", Verdict: "invalid"}}}}, - {RunID: "bad-obligation", Run: verdict.RunResult{Verdict: verdict.Pass, Requirements: []verdict.RequirementResult{{ - ID: "R", Verdict: verdict.Pass, Obligations: []verdict.ObligationResult{{ID: "O", Verdict: "invalid"}}, - }}}}, - {RunID: "bad-document", Document: &ir.Document{SchemaVersion: 2, Project: "p", Hash: "set"}, Run: verdict.RunResult{Verdict: verdict.Pass}}, - {RunID: "bad-plan", VerificationPlan: &ir.VerificationPlan{SchemaVersion: 2}, Run: verdict.RunResult{Verdict: verdict.Pass}}, - {RunID: "bad-evidence", ProviderLogs: map[string]provider.Result{"p": {Evidence: []provider.Evidence{{ID: "bad"}}}}, Run: verdict.RunResult{Verdict: verdict.Pass}}, - } { - if err := store.WriteAttempt(bundle); err == nil { - t.Fatalf("invalid bundle accepted: %+v", bundle) - } - } - badHash := &ir.Document{SchemaVersion: 1, Project: "p", Requirements: []ir.Requirement{{ - ID: "R", Obligations: []ir.Obligation{{Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Extra: map[string]any{"bad": make(chan int)}, - }}}}, - }}} - if err := store.WriteAttempt(&Bundle{ - RunID: "bad-hash", Document: badHash, Run: verdict.RunResult{Verdict: verdict.Pass}, - }); err == nil { - t.Fatal("document hash failure ignored") - } - if err := store.WriteAttempt(&Bundle{ - RunID: "bad-marshal", ProviderLogs: map[string]provider.Result{ - "p": {Extra: map[string]any{"bad": make(chan int)}}, - }, Run: verdict.RunResult{Verdict: verdict.Pass}, - }); err == nil { - t.Fatal("bundle marshal failure ignored") - } -} - -func TestReportFinalizeLoadAndControllerErrors(t *testing.T) { - store, err := NewStore(t.TempDir(), "runs") - if err != nil { - t.Fatal(err) - } - for _, runID := range []string{"../run", ""} { - if err := store.WriteReport(runID, "report.txt", nil); err == nil { - t.Fatal("invalid report run id") - } - if err := store.Finalize(&Bundle{RunID: runID}); err == nil { - t.Fatal("invalid finalize run id") - } - if _, err := store.Load(runID); err == nil { - t.Fatal("invalid load run id") - } - if err := store.WriteRepairPacket(runID, nil); err == nil { - t.Fatal("invalid packet run id") - } - if _, err := store.WriteRepairPacketForAttempt(runID, "attempt", nil); err == nil { - t.Fatal("invalid attempt packet run id") - } - if err := store.WriteAgentLog(runID, "attempt", "stdout", nil); err == nil { - t.Fatal("invalid agent log run id") - } - if err := store.WriteRepairArtifact(runID, "attempt", "agent-exit.json", nil); err == nil { - t.Fatal("invalid repair artifact run id") - } - } - if _, err := store.WriteRepairPacketForAttempt("run", "../attempt", nil); err == nil { - t.Fatal("invalid packet attempt id") - } - if err := store.WriteRepairArtifact("run", "../attempt", "agent-exit.json", nil); err == nil { - t.Fatal("invalid repair artifact attempt id") - } - if _, err := store.WriteRepairPacketForAttempt("run", "attempt", map[string]any{"bad": true}); err == nil { - t.Fatal("invalid repair packet schema") - } - packet := map[string]any{ - "run_id": "run", "verdict": "fail", "failures": []any{}, - "attempt": 1, "max_attempts": 2, - } - packetPath := filepath.Join(store.Dir("packet-conflict"), "attempts", "attempt", "repair-packet.json") - if err := os.MkdirAll(filepath.Dir(packetPath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(packetPath, []byte("different"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := store.WriteRepairPacketForAttempt("packet-conflict", "attempt", packet); err == nil { - t.Fatal("repair packet conflict ignored") - } - - finalized := validTestBundle(t, "finalized") - if err := store.WriteBundle(finalized); err != nil { - t.Fatal(err) - } - for _, operation := range []func() error{ - func() error { return store.WriteReport("finalized", "report.txt", nil) }, - func() error { return store.WriteRepairPacket("finalized", nil) }, - func() error { - _, err := store.WriteRepairPacketForAttempt("finalized", "attempt", nil) - return err - }, - func() error { return store.WriteAgentLog("finalized", "attempt", "stdout", nil) }, - func() error { return store.WriteRepairArtifact("finalized", "attempt", "agent-exit.json", nil) }, - } { - if err := operation(); err == nil { - t.Fatal("finalized run mutated") - } - } -} - -func TestLowLevelAtomicAndPathFailures(t *testing.T) { - root := t.TempDir() - store, err := NewStore(root, "runs") - if err != nil { - t.Fatal(err) - } - if err := store.writeJSONImmutable(filepath.Join(store.Root, "bad.json"), make(chan int)); err == nil { - t.Fatal("immutable JSON marshal failure ignored") - } - if err := store.writeJSONAtomic(filepath.Join(store.Root, "bad-atomic.json"), make(chan int)); err == nil { - t.Fatal("atomic JSON marshal failure ignored") - } - if err := store.writeInitialJSON(filepath.Join(store.Root, "bad-initial.json"), make(chan int)); err == nil { - t.Fatal("initial JSON marshal failure ignored") - } - outside := filepath.Join(root, "outside") - if err := store.writeImmutable(outside, nil); err == nil { - t.Fatal("immutable escape accepted") - } - if err := store.writeAtomic(outside, nil); err == nil { - t.Fatal("atomic escape accepted") - } - if err := store.writeInitialImmutable(outside, nil); err == nil { - t.Fatal("initial immutable escape accepted") - } - initialPath := filepath.Join(store.Root, "initial") - if err := store.writeInitialImmutable(initialPath, []byte("first")); err != nil { - t.Fatal(err) - } - if err := store.writeInitialImmutable(initialPath, []byte("second")); err != nil { - t.Fatal(err) - } - if raw, err := os.ReadFile(initialPath); err != nil || string(raw) != "first" { - t.Fatalf("initial immutable content=%q err=%v", raw, err) - } - path := filepath.Join(store.Root, "same") - if err := store.writeImmutable(path, []byte("same")); err != nil { - t.Fatal(err) - } - if err := store.writeImmutable(path, []byte("same")); err != nil { - t.Fatal(err) - } - if err := store.writeImmutable(path, []byte("different")); err == nil { - t.Fatal("immutable replacement accepted") - } - - oldRead, oldStat, oldMkdir, oldWrite, oldRename := readFile, statFile, mkdirAll, writeFile, renameFile - defer func() { - readFile, statFile, mkdirAll, writeFile, renameFile = oldRead, oldStat, oldMkdir, oldWrite, oldRename - }() - readFile = func(string) ([]byte, error) { return nil, errors.New("read") } - if err := store.writeImmutable(filepath.Join(store.Root, "read-error"), nil); err == nil { - t.Fatal("read error ignored") - } - readFile = oldRead - statFile = func(string) (os.FileInfo, error) { return nil, errors.New("stat") } - if err := store.writeInitialImmutable(filepath.Join(store.Root, "initial-stat-error"), nil); err == nil { - t.Fatal("initial stat error ignored") - } - statFile = oldStat - mkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") } - if err := store.writeAtomic(filepath.Join(store.Root, "mkdir-error"), nil); err == nil { - t.Fatal("mkdir error ignored") - } - mkdirAll = oldMkdir - writeFile = func(string, []byte, os.FileMode) error { return errors.New("write") } - if err := store.writeAtomic(filepath.Join(store.Root, "write-error"), nil); err == nil { - t.Fatal("write error ignored") - } - writeFile = oldWrite - renameFile = func(string, string) error { return errors.New("rename") } - if err := store.writeAtomic(filepath.Join(store.Root, "rename-error"), nil); err == nil { - t.Fatal("rename error ignored") - } -} - -func TestJSONRedactionPreservesNonStringValues(t *testing.T) { - root := t.TempDir() - store, err := NewStore(root, "runs") - if err != nil { - t.Fatal(err) - } - t.Setenv("BOOLEAN_TOKEN", "true") - store.RedactPatterns = []string{"*TOKEN*"} - path := filepath.Join(store.Root, "structured.json") - value := map[string]any{ - "boolean": true, - "number": 42, - "secret": "true", - "nested": []any{false, map[string]any{"value": "prefix true suffix"}}, - } - if err := store.writeJSONAtomic(path, value); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - var decoded map[string]any - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatalf("redaction corrupted JSON: %v\n%s", err, raw) - } - if decoded["boolean"] != true || decoded["number"] != float64(42) { - t.Fatalf("non-string JSON values changed: %#v", decoded) - } - if decoded["secret"] != "[REDACTED]" || - decoded["nested"].([]any)[1].(map[string]any)["value"] != "prefix [REDACTED] suffix" { - t.Fatalf("JSON strings were not redacted: %#v", decoded) - } -} - -func TestSafePathAndManifestFailures(t *testing.T) { - root := t.TempDir() - store, err := NewStore(root, filepath.Join(root, "absolute-runs")) - if err != nil { - t.Fatal(err) - } - oldRel, oldEval, oldStat, oldRead := relativePath, evaluateSymlinks, statFile, readFile - defer func() { - relativePath, evaluateSymlinks, statFile, readFile = oldRel, oldEval, oldStat, oldRead - }() - relativePath = func(string, string) (string, error) { return "", errors.New("relative") } - if _, err := store.safePath(filepath.Join(store.Root, "x")); err == nil { - t.Fatal("relative error ignored") - } - relativePath = oldRel - relativeStore := &Store{ - Root: filepath.Join(root, "relative-runs"), repoRoot: root, relativeRoot: "../escape", - } - if err := os.MkdirAll(relativeStore.Root, 0o755); err != nil { - t.Fatal(err) - } - if _, err := relativeStore.safePath(filepath.Join(relativeStore.Root, "x")); err == nil { - t.Fatal("unsafe relative store root accepted") - } - evaluateSymlinks = func(string) (string, error) { return "", errors.New("symlink") } - if _, err := store.safePath(filepath.Join(store.Root, "x")); err == nil { - t.Fatal("root symlink error ignored") - } - evaluateSymlinks = oldEval - - statFile = func(string) (os.FileInfo, error) { return nil, errors.New("stat") } - if err := store.ensureOpen("run"); err == nil { - t.Fatal("stat error ignored") - } - statFile = oldStat - - missing := filepath.Join(root, "missing") - if _, err := hashArtifacts(missing); err == nil { - t.Fatal("walk error ignored") - } - manifestRoot := t.TempDir() - if err := os.Mkdir(filepath.Join(manifestRoot, "dir"), 0o755); err != nil { - t.Fatal(err) - } - for _, name := range []string{"a", "b", "manifest.json", "final-verdict.json", ".x.tmp-y"} { - if err := os.WriteFile(filepath.Join(manifestRoot, name), []byte(name), 0o644); err != nil { - t.Fatal(err) - } - } - if err := os.Symlink("a", filepath.Join(manifestRoot, "link")); err != nil { - t.Fatal(err) - } - if _, err := hashArtifacts(manifestRoot); err == nil { - t.Fatal("artifact symlink accepted") - } - if err := os.Remove(filepath.Join(manifestRoot, "link")); err != nil { - t.Fatal(err) - } - artifacts, err := hashArtifacts(manifestRoot) - if err != nil || len(artifacts) != 2 || artifacts[0].Path != "a" { - t.Fatalf("%+v %v", artifacts, err) - } - readFile = func(path string) ([]byte, error) { - if filepath.Base(path) == "a" { - return nil, errors.New("read") - } - return oldRead(path) - } - if _, err := hashArtifacts(manifestRoot); err == nil { - t.Fatal("artifact read error ignored") - } -} - -func TestOperationSafePathFailures(t *testing.T) { - root := t.TempDir() - store, err := NewStore(root, "runs") - if err != nil { - t.Fatal(err) - } - oldRel := relativePath - defer func() { relativePath = oldRel }() - calls := 0 - relativePath = func(base, target string) (string, error) { - calls++ - if calls == 2 { - return "", errors.New("second safe path") - } - return oldRel(base, target) - } - if err := store.WriteAttempt(&Bundle{ - RunID: "run", Run: verdict.RunResult{Verdict: verdict.Pass}, - }); err == nil { - t.Fatal("run directory safe-path failure ignored") - } - - relativePath = func(string, string) (string, error) { return "", errors.New("safe path") } - if err := store.Finalize(&Bundle{RunID: "run"}); err == nil { - t.Fatal("finalize safe-path failure ignored") - } - if _, err := store.LoadLatest(); err == nil { - t.Fatal("latest safe-path failure ignored") - } - if _, err := store.Load("run"); err == nil { - t.Fatal("load safe-path failure ignored") - } -} - -func TestFinalizeArtifactAndWriteFailures(t *testing.T) { - root := t.TempDir() - store, err := NewStore(root, "runs") - if err != nil { - t.Fatal(err) - } - runDir := store.Dir("symlink-run") - if err := os.MkdirAll(runDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(root, filepath.Join(runDir, "link")); err != nil { - t.Fatal(err) - } - if err := store.Finalize(&Bundle{RunID: "symlink-run"}); err == nil { - t.Fatal("manifest symlink accepted") - } - - conflictDir := store.Dir("manifest-conflict") - if err := os.MkdirAll(conflictDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(conflictDir, "manifest.json"), []byte("different"), 0o644); err != nil { - t.Fatal(err) - } - if err := store.Finalize(&Bundle{RunID: "manifest-conflict"}); err == nil { - t.Fatal("manifest conflict ignored") - } - - oldWrite := writeFile - defer func() { writeFile = oldWrite }() - calls := 0 - writeFile = func(path string, content []byte, mode os.FileMode) error { - calls++ - if calls == 2 { - return errors.New("final") - } - return oldWrite(path, content, mode) - } - if err := store.Finalize(&Bundle{RunID: "final-write"}); err == nil { - t.Fatal("final verdict write failure ignored") - } -} - -func TestNamesAndIdentifiers(t *testing.T) { - for _, value := range []string{"", ".", "..", "a/b", `a\b`} { - if validateRunID(value) == nil || validateAttemptID(value) == nil { - t.Fatal(value) - } - } - if validateRunID("run") != nil || validateAttemptID("attempt") != nil { - t.Fatal("valid identifiers rejected") - } - if safeName("") != "provider" || safeName("a/b") != "a_b" { - t.Fatal("safe name") - } - keys := sortedProviderKeys(map[string]provider.Result{"b": {}, "a": {}}) - if strings.Join(keys, "") != "ab" { - t.Fatal(keys) - } -} diff --git a/internal/evidence/bundle_v1_test.go b/internal/evidence/bundle_v1_test.go deleted file mode 100644 index 9e2e3b0..0000000 --- a/internal/evidence/bundle_v1_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package evidence_test - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/git" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/repair" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestCompleteBundleManifestRedactionAndImmutability(t *testing.T) { - root := t.TempDir() - store, err := evidence.NewStore(root, ".intentci/runs") - if err != nil { - t.Fatal(err) - } - t.Setenv("API_TOKEN", "literal-secret") - store.RedactPatterns = []string{"*TOKEN*"} - document := &ir.Document{ - SchemaVersion: 1, Project: "demo", - Requirements: []ir.Requirement{{ - ID: "REQ-001", Title: "Requirement", Status: "active", Priority: "required", - Intent: "Keep behavior correct.", SourcePath: ".intentci/requirements/REQ-001.md", - Obligations: []ir.Obligation{{ - ID: "OBL-001", Statement: "It passes.", Required: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", ID: "check", Run: "true"}}, - }}, - }}, - } - if err := document.ComputeHashes(); err != nil { - t.Fatal(err) - } - plan, err := ir.BuildVerificationPlan(document, document.Requirements) - if err != nil { - t.Fatal(err) - } - now := time.Now().UTC() - passed := true - record := provider.Evidence{ - SchemaVersion: "1.0", ID: "evidence", RunID: "run", AttemptID: "attempt-001", - RequirementID: "REQ-001", ObligationID: "OBL-001", VerifierID: "check", - Provider: "command", ProviderVersion: "1.0.0", Class: "deterministic", - Status: "passed", Summary: "literal-secret passed", Passed: &passed, - RepositoryCommit: "head", BaseCommit: "base", DiffHash: strings.Repeat("d", 64), - RequirementHash: document.Requirements[0].Hash, - ObligationHash: document.Requirements[0].Obligations[0].Hash, - PlanHash: plan.Hash, StartedAt: now, CompletedAt: now, - } - bundle := &evidence.Bundle{ - RunID: "run", AttemptID: "attempt-001", CreatedAt: now, Root: root, - HeadCommit: "head", BaseCommit: "base", IRHash: document.Hash, - Document: document, VerificationPlan: plan, - RepositoryState: &git.State{ - Root: root, BaseCommit: "base", HeadCommit: "head", - DiffHash: strings.Repeat("d", 64), DiffPatch: "literal-secret diff", - }, - ProviderLogs: map[string]provider.Result{ - "REQ-001/OBL-001/check": { - Provider: "command", ProviderVersion: "1.0.0", Status: "completed", - Stdout: "literal-secret stdout", Stderr: "API_TOKEN=visible", Evidence: []provider.Evidence{record}, - }, - }, - Run: verdict.RunResult{ - Verdict: verdict.Pass, - Requirements: []verdict.RequirementResult{{ - ID: "REQ-001", Title: "Requirement", Priority: "required", Verdict: verdict.Pass, - Obligations: []verdict.ObligationResult{{ - ID: "OBL-001", Statement: "It passes.", Required: true, - Verdict: verdict.Pass, Evidence: []provider.Evidence{record}, - }}, - }}, - }, - } - if err := store.WriteAttempt(bundle); err != nil { - t.Fatal(err) - } - for name, content := range map[string]string{ - "report.txt": "literal-secret report\n", - "report.json": `{"run":"run"}`, - "report.junit.xml": ``, - } { - if err := store.WriteReport("run", name, []byte(content)); err != nil { - t.Fatal(err) - } - } - if err := store.Finalize(bundle); err != nil { - t.Fatal(err) - } - - runDir := store.Dir("run") - for _, relative := range []string{ - "compiled-intent.json", "verification-plan.json", "repository-state.json", "diff.patch", - "attempts/attempt-001/evidence.json", "attempts/attempt-001/verdict.json", - "attempts/attempt-001/logs/REQ-001_OBL-001_check.stdout", - "attempts/attempt-001/logs/REQ-001_OBL-001_check.stderr", - "final-verdict.json", "manifest.json", "report.txt", "report.json", "report.junit.xml", - } { - data, err := os.ReadFile(filepath.Join(runDir, relative)) - if err != nil { - t.Fatalf("%s: %v", relative, err) - } - if strings.Contains(string(data), "literal-secret") || strings.Contains(string(data), "visible") { - t.Fatalf("%s was not redacted: %s", relative, data) - } - } - manifestRaw, err := os.ReadFile(filepath.Join(runDir, "manifest.json")) - if err != nil { - t.Fatal(err) - } - var manifest evidence.Manifest - if err := json.Unmarshal(manifestRaw, &manifest); err != nil { - t.Fatal(err) - } - for _, artifact := range manifest.Artifacts { - if artifact.Path == "manifest.json" || artifact.Path == "final-verdict.json" { - t.Fatalf("hash cycle: %+v", artifact) - } - content, err := os.ReadFile(filepath.Join(runDir, filepath.FromSlash(artifact.Path))) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(content) - if artifact.SHA256 != hex.EncodeToString(sum[:]) { - t.Fatalf("%s hash mismatch", artifact.Path) - } - } - sum := sha256.Sum256(manifestRaw) - if bundle.ManifestHash != hex.EncodeToString(sum[:]) { - t.Fatal("final manifest hash mismatch") - } - if err := store.WriteAgentLog("run", "attempt-001", "stdout", []byte("late")); err == nil { - t.Fatal("finalized run was mutable") - } -} - -func TestRepairArtifactsAndUnsafeEvidencePaths(t *testing.T) { - root := t.TempDir() - store, err := evidence.NewStore(root, ".intentci/runs") - if err != nil { - t.Fatal(err) - } - packet := repair.Packet{ - RunID: "run", Verdict: verdict.Fail, Summary: "failed", - Failures: []repair.Failure{{Requirement: "REQ", Obligation: "OBL", Verdict: verdict.Fail, Reason: "x"}}, - Attempt: 1, MaxAttempts: 2, - } - path, err := store.WriteRepairPacketForAttempt("run", "attempt-001", packet) - if err != nil || !strings.HasSuffix(path, "repair-packet.json") { - t.Fatalf("%s %v", path, err) - } - for _, stream := range []string{"stdout", "stderr"} { - if err := store.WriteAgentLog("run", "attempt-001", stream, []byte(stream)); err != nil { - t.Fatal(err) - } - } - for _, name := range []string{"patch-before.diff", "patch-after.diff", "agent-exit.json"} { - if err := store.WriteRepairArtifact("run", "attempt-001", name, []byte(name)); err != nil { - t.Fatal(err) - } - } - if _, err := store.WriteRepairPacketForAttempt("../run", "attempt-001", packet); err == nil { - t.Fatal("unsafe run id accepted") - } - if err := store.WriteAgentLog("run", "../attempt", "stdout", nil); err == nil { - t.Fatal("unsafe attempt id accepted") - } - if err := store.WriteAgentLog("run", "attempt-001", "other", nil); err == nil { - t.Fatal("unsafe stream accepted") - } - if err := store.WriteRepairArtifact("run", "attempt-001", "other", nil); err == nil { - t.Fatal("unsafe artifact accepted") - } - if err := store.WriteReport("run", "other", nil); err == nil { - t.Fatal("unsafe report accepted") - } - - outside := t.TempDir() - link := filepath.Join(root, ".intentci", "linked-runs") - if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, link); err != nil { - t.Fatal(err) - } - if _, err := evidence.NewStore(root, ".intentci/linked-runs"); err == nil { - t.Fatal("symlink evidence escape accepted") - } -} diff --git a/internal/evidence/fuzz_test.go b/internal/evidence/fuzz_test.go deleted file mode 100644 index 39839ca..0000000 --- a/internal/evidence/fuzz_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package evidence - -import ( - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/oklog/ulid/v2" -) - -func FuzzV1ManifestHashing(f *testing.F) { - f.Add([]byte("alpha"), []byte("beta")) - f.Add([]byte{}, []byte{0, 1, 2}) - f.Fuzz(func(t *testing.T, firstContent, secondContent []byte) { - if len(firstContent)+len(secondContent) > 4096 { - t.Skip() - } - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "a"), firstContent, 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "b"), secondContent, 0o600); err != nil { - t.Fatal(err) - } - first, err := hashArtifacts(root) - if err != nil { - t.Fatal(err) - } - second, err := hashArtifacts(root) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(first, second) { - t.Fatalf("manifest hashing is nondeterministic:\n%+v\n%+v", first, second) - } - }) -} - -func FuzzV1RunIDOrdering(f *testing.F) { - f.Add(uint8(2)) - f.Add(uint8(16)) - f.Fuzz(func(t *testing.T, requested uint8) { - count := int(requested%32) + 2 - previous := NewRunID() - if _, err := ulid.ParseStrict(previous); err != nil { - t.Fatal(err) - } - for index := 1; index < count; index++ { - current := NewRunID() - if _, err := ulid.ParseStrict(current); err != nil { - t.Fatal(err) - } - if current <= previous { - t.Fatalf("run IDs are not strictly ordered: %q then %q", previous, current) - } - previous = current - } - }) -} diff --git a/internal/executor/benchmark_test.go b/internal/executor/benchmark_test.go deleted file mode 100644 index 08323f8..0000000 --- a/internal/executor/benchmark_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package executor_test - -import ( - "context" - "fmt" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/executor" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" -) - -type benchmarkProvider struct{} - -func (benchmarkProvider) Name() string { return "benchmark" } -func (benchmarkProvider) Version() string { return "1.0.0" } -func (benchmarkProvider) Validate(ir.ProviderSpec) []provider.Diagnostic { - return nil -} -func (benchmarkProvider) Execute(_ context.Context, request provider.Request) provider.Result { - passed := true - return provider.Result{ - Provider: "benchmark", ProviderVersion: "1.0.0", Status: "completed", - Evidence: []provider.Evidence{{ - ID: request.Spec.ID, Class: "deterministic", Summary: "passed", Passed: &passed, - }}, - } -} - -func BenchmarkV1IncrementalSchedulingOverhead(b *testing.B) { - requirements := make([]ir.Requirement, 100) - for index := range requirements { - requirements[index] = ir.Requirement{ - ID: fmt.Sprintf("REQ-%03d", index), Priority: "required", - Obligations: []ir.Obligation{{ - ID: fmt.Sprintf("OBL-%03d", index), Required: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "benchmark", ID: fmt.Sprintf("check-%03d", index), - }}, - }}, - } - } - cfg := config.Default() - cfg.Verification.MaxParallel = 4 - registry := provider.NewRegistry(benchmarkProvider{}) - root := b.TempDir() - b.ResetTimer() - for iteration := 0; iteration < b.N; iteration++ { - _, results := executor.Run(context.Background(), requirements, executor.Options{ - Root: root, Config: cfg, Registry: registry, NoCache: true, - RunID: fmt.Sprintf("run-%d", iteration), AttemptID: "attempt-001", - }) - if len(results) != len(requirements) { - b.Fatalf("scheduled %d requirements", len(results)) - } - } -} diff --git a/internal/executor/executor.go b/internal/executor/executor.go deleted file mode 100644 index af920e7..0000000 --- a/internal/executor/executor.go +++ /dev/null @@ -1,1026 +0,0 @@ -package executor - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "sort" - "strings" - "sync" - "time" - - "github.com/bmatcuk/doublestar/v4" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/security" - "github.com/hypertrial/intentci/internal/verdict" -) - -type temporaryFile interface { - io.Writer - Close() error - Name() string -} - -var makeDirectories = os.MkdirAll -var openFile = os.Open -var createTemporary = func(directory, pattern string) (temporaryFile, error) { - return os.CreateTemp(directory, pattern) -} -var renamePath = os.Rename -var lstatPath = os.Lstat -var readlinkPath = os.Readlink -var readPath = os.ReadFile - -// Options configures execution. -type Options struct { - Root string - Config *config.Config - Registry *provider.Registry - BaseCommit string - HeadCommit string - DiffHash string - ChangedFiles []string - Changes []provider.Change - RunID string - AttemptID string - EvidenceDir string - IRHash string - PlanHash string - ProviderID string - NoCache bool - CacheDir string -} - -// LeafResult maps provider leaf id -> result. -type LeafResult map[string]provider.Result - -type job struct { - req ir.Requirement - obl ir.Obligation - key string - fullKey string - spec ir.ProviderSpec - dependsOn []string -} - -type prepared struct { - req ir.Requirement - obl ir.Obligation - verify ir.VerifyNode - jobs []job -} - -// Run executes all provider leaves for selected requirements. -func Run(ctx context.Context, reqs []ir.Requirement, opt Options) (map[string]LeafResult, []verdict.RequirementResult) { - if opt.Config == nil { - opt.Config = config.Default() - } - if opt.Registry == nil { - opt.Registry = provider.DefaultRegistry() - } - var preparedObs []prepared - var jobs []job - providerIDs := map[string]string{} - obligationJobKeys := map[string][]string{} - for _, requirement := range reqs { - for _, obligation := range requirement.Obligations { - counter := 0 - verify := assignLeafIDs(obligation.Verify, &counter) - var obligationJobs []job - collectLeaves(verify, func(spec ir.ProviderSpec) { - fullKey := requirement.ID + "/" + obligation.ID + "/" + spec.ID - current := job{ - req: requirement, obl: obligation, key: spec.ID, - fullKey: fullKey, spec: spec, - } - obligationJobs = append(obligationJobs, current) - jobs = append(jobs, current) - providerIDs[requirement.ID+"/"+spec.ID] = fullKey - }) - preparedObs = append(preparedObs, prepared{ - req: requirement, obl: obligation, verify: verify, jobs: obligationJobs, - }) - for _, current := range obligationJobs { - obligationJobKeys[requirement.ID+"/"+obligation.ID] = append( - obligationJobKeys[requirement.ID+"/"+obligation.ID], current.fullKey, - ) - } - } - } - for index := range jobs { - for _, dependency := range jobs[index].spec.DependsOn { - if key := providerIDs[jobs[index].req.ID+"/"+dependency]; key != "" { - jobs[index].dependsOn = append(jobs[index].dependsOn, key) - } - } - for _, dependency := range jobs[index].obl.DependsOn { - jobs[index].dependsOn = append( - jobs[index].dependsOn, obligationJobKeys[jobs[index].req.ID+"/"+dependency]..., - ) - } - } - - results := executeJobs(ctx, jobs, opt) - normalizeResults(results, jobs, opt) - if mutateResults != nil { - mutateResults(results) - } - - byReq := map[string]LeafResult{} - obligationResults := map[string]map[string]verdict.ObligationResult{} - for _, item := range preparedObs { - leaves := LeafResult{} - for _, current := range item.jobs { - if result, ok := results[current.fullKey]; ok { - leaves[current.key] = result - } - } - if byReq[item.req.ID] == nil { - byReq[item.req.ID] = LeafResult{} - } - for key, result := range leaves { - byReq[item.req.ID][item.obl.ID+"/"+key] = result - } - value, reason, evidence := verdict.EvaluateNodeWithPolicy(item.verify, leaves, verdict.EvidencePolicy{ - Class: item.obl.EvidenceClass, ConfidenceThreshold: item.obl.ConfidenceThreshold, - }) - if item.obl.ManualReview && value != verdict.Fail && value != verdict.Error { - value = verdict.ReviewRequired - reason = "obligation requires manual review" - } - if obligationResults[item.req.ID] == nil { - obligationResults[item.req.ID] = map[string]verdict.ObligationResult{} - } - obligationResults[item.req.ID][item.obl.ID] = verdict.ObligationResult{ - ID: item.obl.ID, Statement: item.obl.Statement, Required: item.obl.Required, - Verdict: value, Reason: reason, Evidence: evidence, - } - } - for _, item := range preparedObs { - result := obligationResults[item.req.ID][item.obl.ID] - for _, dependency := range item.obl.DependsOn { - if prior, ok := obligationResults[item.req.ID][dependency]; ok && prior.Verdict != verdict.Pass { - result.Verdict = verdict.Unproven - result.Reason = "dependency " + dependency + " did not pass" - break - } - } - obligationResults[item.req.ID][item.obl.ID] = result - } - - var requirementResults []verdict.RequirementResult - for _, requirement := range reqs { - obligations := make([]verdict.ObligationResult, 0, len(requirement.Obligations)) - for _, obligation := range requirement.Obligations { - if result, ok := obligationResults[requirement.ID][obligation.ID]; ok { - obligations = append(obligations, result) - } - } - requirementResults = append(requirementResults, verdict.AggregateRequirement(requirement, obligations)) - } - requirementResults = applyRequirementDependencies(reqs, requirementResults) - return byReq, requirementResults -} - -func applyRequirementDependencies(requirements []ir.Requirement, results []verdict.RequirementResult) []verdict.RequirementResult { - byID := make(map[string]verdict.RequirementResult, len(results)) - for _, result := range results { - byID[result.ID] = result - } - for changed := true; changed; { - changed = false - for index, requirement := range requirements { - for _, dependency := range requirement.DependsOn { - dependencyResult, ok := byID[dependency] - if !ok || dependencyResult.Verdict != verdict.Pass { - if results[index].Verdict == verdict.Pass { - results[index].Verdict = verdict.Unproven - results[index].Reason = "requirement dependency " + dependency + " did not pass" - byID[results[index].ID] = results[index] - changed = true - } - break - } - } - } - } - return results -} - -func normalizeResults(results map[string]provider.Result, jobs []job, opt Options) { - byKey := make(map[string]job, len(jobs)) - for _, current := range jobs { - byKey[current.fullKey] = current - } - for key, result := range results { - current, ok := byKey[key] - if !ok { - continue - } - version := result.ProviderVersion - if version == "" { - if implementation, found := opt.Registry.Get(current.spec.Provider); found { - version = implementation.Version() - } - } - if result.Status != "completed" && result.Status != "error" && result.Status != "skipped" { - result.Status = "error" - result.Diagnostics = append(result.Diagnostics, "provider returned invalid status") - } - for _, record := range result.Evidence { - if !contains([]string{"deterministic", "probabilistic", "human", "informational"}, record.Class) || - (record.Confidence != nil && (*record.Confidence < 0 || *record.Confidence > 1)) { - result.Status = "error" - result.Diagnostics = append(result.Diagnostics, "provider returned invalid evidence") - result.Evidence = []provider.Evidence{{ - ID: current.key, Class: "deterministic", Status: "error", - Summary: "provider returned invalid evidence", Passed: boolPtr(false), - }} - break - } - } - now := time.Now().UTC() - for index := range result.Evidence { - record := &result.Evidence[index] - if record.ID == "" { - record.ID = current.key - } - record.SchemaVersion = "1.0" - record.RunID = opt.RunID - record.AttemptID = opt.AttemptID - record.RequirementID = current.req.ID - record.ObligationID = current.obl.ID - record.VerifierID = current.spec.ID - record.Provider = current.spec.Provider - record.ProviderVersion = version - record.RepositoryCommit = firstNonEmpty(opt.HeadCommit, "unknown") - record.BaseCommit = firstNonEmpty(opt.BaseCommit, "unknown") - record.DiffHash = firstNonEmpty(opt.DiffHash, ir.HashBytes(nil)) - record.RequirementHash = current.req.Hash - record.ObligationHash = current.obl.Hash - record.PlanHash = firstNonEmpty(opt.PlanHash, opt.IRHash) - if record.StartedAt.IsZero() { - record.StartedAt = now - } - if record.CompletedAt.IsZero() { - record.CompletedAt = now - } - if record.Status == "" { - switch { - case result.Status == "error": - record.Status = "error" - case result.Status == "skipped": - record.Status = "skipped" - case record.Passed == nil: - record.Status = "unknown" - case *record.Passed: - record.Status = "passed" - default: - record.Status = "failed" - } - } - } - results[key] = result - } -} - -func executeJobs(ctx context.Context, jobs []job, opt Options) map[string]provider.Result { - pending := append([]job{}, jobs...) - results := map[string]provider.Result{} - maximum := opt.Config.MaxParallelOr(4) - for len(pending) > 0 { - if err := ctx.Err(); err != nil { - for _, current := range pending { - results[current.fullKey] = provider.Result{ - Provider: current.spec.Provider, Status: "error", Diagnostics: []string{err.Error()}, - Evidence: []provider.Evidence{{ - ID: current.key, Class: "deterministic", Status: "error", - Summary: "verification interrupted: " + err.Error(), Passed: boolPtr(false), - }}, - } - } - break - } - var ready []job - for _, current := range pending { - dependenciesDone := true - for _, dependency := range current.dependsOn { - if _, ok := results[dependency]; !ok { - dependenciesDone = false - } - } - if dependenciesDone { - ready = append(ready, current) - } - } - if len(ready) == 0 { - for _, current := range pending { - results[current.fullKey] = skippedResult(current, "unresolved verifier dependency") - } - break - } - sort.Slice(ready, func(i, j int) bool { return ready[i].fullKey < ready[j].fullKey }) - batch := compatibleBatch(ready, maximum) - var mutex sync.Mutex - var wait sync.WaitGroup - for _, current := range batch { - current := current - wait.Add(1) - go func() { - defer wait.Done() - result := executeJob(ctx, current, opt) - mutex.Lock() - results[current.fullKey] = result - mutex.Unlock() - }() - } - wait.Wait() - ran := map[string]bool{} - stop := false - for _, current := range batch { - ran[current.fullKey] = true - if opt.Config.Verification.FailFast && resultFailed(results[current.fullKey]) { - stop = true - } - } - next := pending[:0] - for _, current := range pending { - if !ran[current.fullKey] { - next = append(next, current) - } - } - pending = next - if stop { - for _, current := range pending { - results[current.fullKey] = skippedResult(current, "fail-fast cancellation") - } - break - } - } - return results -} - -func compatibleBatch(ready []job, maximum int) []job { - var batch []job - outputs := map[string]bool{} - for _, current := range ready { - if len(batch) >= maximum { - break - } - if current.spec.Exclusive { - if len(batch) == 0 { - return []job{current} - } - continue - } - conflict := false - for _, output := range jobWritePatterns(current.spec) { - for scheduled := range outputs { - if outputPatternsConflict(output, scheduled) { - conflict = true - break - } - } - } - if conflict { - continue - } - batch = append(batch, current) - for _, output := range jobWritePatterns(current.spec) { - outputs[output] = true - } - } - if len(batch) == 0 { - return ready[:1] - } - return batch -} - -func jobWritePatterns(spec ir.ProviderSpec) []string { - output := append([]string{}, spec.Outputs...) - return append(output, spec.Artifacts...) -} - -func outputPatternsConflict(left, right string) bool { - if left == right { - return true - } - if matched, _ := doublestar.Match(left, right); matched { - return true - } - if matched, _ := doublestar.Match(right, left); matched { - return true - } - leftPrefix := left[:wildcardIndex(left)] - rightPrefix := right[:wildcardIndex(right)] - return strings.HasPrefix(leftPrefix, rightPrefix) || strings.HasPrefix(rightPrefix, leftPrefix) -} - -func wildcardIndex(value string) int { - index := len(value) - for _, token := range []string{"*", "?", "["} { - if found := strings.Index(value, token); found >= 0 && found < index { - index = found - } - } - return index -} - -func executeJob(ctx context.Context, current job, opt Options) provider.Result { - if err := ctx.Err(); err != nil { - return provider.Result{ - Provider: current.spec.Provider, Status: "error", Diagnostics: []string{err.Error()}, - Evidence: []provider.Evidence{{ - ID: current.key, Class: "deterministic", Status: "error", - Summary: "verification interrupted: " + err.Error(), Passed: boolPtr(false), - }}, - } - } - if opt.ProviderID != "" && current.spec.ID != opt.ProviderID { - return skippedResult(current, "not selected by --provider") - } - if len(current.obl.Platforms) > 0 && !contains(current.obl.Platforms, runtime.GOOS) { - return skippedResult(current, "unsupported platform "+runtime.GOOS) - } - implementation, ok := opt.Registry.Get(current.spec.Provider) - if !ok { - return provider.Result{ - Provider: current.spec.Provider, Status: "error", - Diagnostics: []string{"unknown provider"}, - Evidence: []provider.Evidence{{ - ID: current.key, Class: "deterministic", Summary: "unknown provider", Passed: boolPtr(false), - }}, - } - } - spec := current.spec - if spec.WorkingDirectory == "" { - spec.WorkingDirectory = opt.Config.Verification.WorkingDirectory - } - request := provider.Request{ - RunID: opt.RunID, AttemptID: opt.AttemptID, - RequirementID: current.req.ID, ObligationID: current.obl.ID, - Root: opt.Root, EvidenceDir: opt.EvidenceDir, - BaseCommit: opt.BaseCommit, HeadCommit: opt.HeadCommit, DiffHash: opt.DiffHash, - RequirementHash: current.req.Hash, ObligationHash: current.obl.Hash, - PlanHash: firstNonEmpty(opt.PlanHash, opt.IRHash), - EvidenceClass: current.obl.EvidenceClass, - ConfidenceThreshold: current.obl.ConfidenceThreshold, - ChangedFiles: opt.ChangedFiles, Spec: spec, - Changes: opt.Changes, - Timeout: effectiveTimeout(spec.Timeout, current.obl.Timeout, current.req.Timeout, opt.Config.Verification.DefaultTimeout), - RetainStdout: opt.Config.Evidence.RetainStdout, - RetainStderr: opt.Config.Evidence.RetainStderr, - } - request.ExecutionAttempt = 1 - cacheKey, cacheable := cacheKey(request, current, implementation.Version(), opt) - if implementation.Version() == "external" || len(spec.Outputs) > 0 || len(spec.Artifacts) > 0 { - cacheable = false - } - if cacheable && !opt.NoCache { - if cached, ok := loadCache(opt.CacheDir, cacheKey); ok { - cached = redactResult(cached, opt.Config.Evidence.Redact.Environment) - source, _ := json.Marshal(cached) - sourceHash := hash(source) - cached.FromCache = true - cached.SourceEvidenceHash = sourceHash - cached.DurationMS = 0 - now := time.Now().UTC() - for index := range cached.Evidence { - cached.Evidence[index].ID += "-cached-" + opt.RunID - cached.Evidence[index].RunID = opt.RunID - cached.Evidence[index].AttemptID = opt.AttemptID - cached.Evidence[index].SourceEvidenceHash = sourceHash - cached.Evidence[index].StartedAt = now - cached.Evidence[index].CompletedAt = now - cached.Evidence[index].Artifacts = nil - } - return cached - } - } - attempts, backoff := retrySettings(spec.Retry, current.obl.Retry) - var result provider.Result - var observations []provider.Evidence - finalEvidenceCount := 0 - var stdout, stderr strings.Builder - for attempt := 1; attempt <= attempts; attempt++ { - request.ExecutionAttempt = attempt - result = implementation.Execute(ctx, request) - enrichEvidence(&result, request, implementation) - validateOutputs(request, &result) - if attempts > 1 { - for index := range result.Evidence { - result.Evidence[index].ID += fmt.Sprintf("-try-%d", attempt) - } - } - if attempts > 1 { - appendAttemptLog(&stdout, attempt, result.Stdout) - appendAttemptLog(&stderr, attempt, result.Stderr) - } - if err := collectArtifacts(request, &result); err != nil { - result.Status = "error" - result.Diagnostics = append(result.Diagnostics, err.Error()) - result.SecurityViolation = security.IsPathViolation(err) - } - observations = append(observations, result.Evidence...) - finalEvidenceCount = len(result.Evidence) - if !resultFailed(result) || attempt == attempts || ctx.Err() != nil { - break - } - if backoff > 0 { - timer := time.NewTimer(backoff) - select { - case <-ctx.Done(): - timer.Stop() - case <-timer.C: - } - } - } - if !resultFailed(result) && len(observations) > finalEvidenceCount { - for index := 0; index < len(observations)-finalEvidenceCount; index++ { - if observations[index].Data == nil { - observations[index].Data = map[string]any{} - } - observations[index].Data["retry_superseded"] = true - } - } - result.Evidence = observations - if attempts > 1 { - result.Stdout = stdout.String() - result.Stderr = stderr.String() - } - if cacheable && !opt.NoCache && cacheSuccess(result) { - _ = saveCache(opt.CacheDir, cacheKey, redactResult(result, opt.Config.Evidence.Redact.Environment)) - } - return result -} - -func validateOutputs(request provider.Request, result *provider.Result) { - if result.Status != "completed" { - return - } - for _, pattern := range request.Spec.Outputs { - matches, err := doublestar.FilepathGlob(filepath.Join(request.Root, filepath.FromSlash(pattern))) - if err == nil && len(matches) > 0 { - continue - } - result.Evidence = append(result.Evidence, provider.Evidence{ - ID: firstNonEmpty(request.Spec.ID, request.Spec.Provider) + "-output", - Class: "deterministic", Status: "failed", - Summary: fmt.Sprintf("required output %q was not produced", pattern), Passed: boolPtr(false), - }) - } -} - -func appendAttemptLog(output *strings.Builder, attempt int, content string) { - if content == "" { - return - } - fmt.Fprintf(output, "== provider attempt %d ==\n", attempt) - output.WriteString(content) - if !strings.HasSuffix(content, "\n") { - output.WriteByte('\n') - } -} - -func redactResult(result provider.Result, patterns []string) provider.Result { - raw, err := json.Marshal(result) - if err != nil { - return result - } - redactor := security.NewRedactor(patterns, os.Environ()) - var generic any - _ = json.Unmarshal(raw, &generic) - redactJSONStrings(generic, redactor) - raw, _ = json.Marshal(generic) - var redacted provider.Result - _ = json.Unmarshal(raw, &redacted) - return redacted -} - -func redactJSONStrings(value any, redactor security.Redactor) { - switch typed := value.(type) { - case map[string]any: - for key, child := range typed { - if text, ok := child.(string); ok { - typed[key] = redactor.Redact(text) - } else { - redactJSONStrings(child, redactor) - } - } - case []any: - for index, child := range typed { - if text, ok := child.(string); ok { - typed[index] = redactor.Redact(text) - } else { - redactJSONStrings(child, redactor) - } - } - } -} - -func effectiveTimeout(values ...string) time.Duration { - for _, value := range values { - if value != "" { - if duration, err := config.ParseDuration(value); err == nil { - return duration - } - } - } - return 10 * time.Minute -} - -func retrySettings(providerRetry, obligationRetry ir.Retry) (int, time.Duration) { - retry := providerRetry - if retry.Attempts == 0 { - retry = obligationRetry - } - attempts := retry.Attempts - if attempts < 1 { - attempts = 1 - } - backoff, _ := time.ParseDuration(retry.Backoff) - return attempts, backoff -} - -func enrichEvidence(result *provider.Result, request provider.Request, implementation provider.Provider) { - for index := range result.Evidence { - evidence := &result.Evidence[index] - evidence.SchemaVersion = "1.0" - evidence.RunID = request.RunID - evidence.AttemptID = request.AttemptID - evidence.RequirementID = request.RequirementID - evidence.ObligationID = request.ObligationID - evidence.VerifierID = request.Spec.ID - evidence.Provider = implementation.Name() - if result.ProviderVersion != "" { - evidence.ProviderVersion = result.ProviderVersion - } else { - evidence.ProviderVersion = implementation.Version() - } - evidence.RepositoryCommit = firstNonEmpty(request.HeadCommit, "unknown") - evidence.BaseCommit = firstNonEmpty(request.BaseCommit, "unknown") - evidence.DiffHash = firstNonEmpty(request.DiffHash, ir.HashBytes(nil)) - evidence.RequirementHash = request.RequirementHash - evidence.ObligationHash = request.ObligationHash - evidence.PlanHash = request.PlanHash - if evidence.Class == "" { - evidence.Class = firstNonEmpty(request.Spec.EvidenceClass, request.EvidenceClass, "deterministic") - } - if evidence.Status == "" { - switch { - case result.Status == "error": - evidence.Status = "error" - case result.Status == "skipped": - evidence.Status = "skipped" - case evidence.Passed == nil: - evidence.Status = "unknown" - case *evidence.Passed: - evidence.Status = "passed" - default: - evidence.Status = "failed" - } - } - now := time.Now().UTC() - if evidence.StartedAt.IsZero() { - evidence.StartedAt = now - } - if evidence.CompletedAt.IsZero() { - evidence.CompletedAt = now - } - } -} - -func collectArtifacts(request provider.Request, result *provider.Result) error { - if len(request.Spec.Artifacts) == 0 { - return nil - } - if err := makeDirectories(request.EvidenceDir, 0o755); err != nil { - return err - } - for _, pattern := range request.Spec.Artifacts { - matches, err := doublestar.FilepathGlob(filepath.Join(request.Root, filepath.FromSlash(pattern))) - if err != nil { - return fmt.Errorf("collect artifact %q: %w", pattern, err) - } - for _, match := range matches { - relative, _ := filepath.Rel(request.Root, match) - relative = filepath.ToSlash(relative) - source, err := security.ResolveInside(request.Root, relative) - if err != nil { - return fmt.Errorf("collect artifact %q: %w", relative, err) - } - info, err := lstatPath(source) - if err != nil { - return err - } - if !info.Mode().IsRegular() { - return fmt.Errorf("artifact must be a regular file: %s", relative) - } - destinationRelative := filepath.Join( - safeSegment(request.RequirementID), safeSegment(request.ObligationID), - safeSegment(firstNonEmpty(request.Spec.ID, request.Spec.Provider)), filepath.FromSlash(relative), - ) - if request.ExecutionAttempt > 1 { - destinationRelative = filepath.Join( - fmt.Sprintf("try-%03d", request.ExecutionAttempt), destinationRelative, - ) - } - destination, err := security.ResolveInside(request.EvidenceDir, destinationRelative) - if err != nil { - return err - } - hashValue, err := copyArtifact(source, destination) - if err != nil { - return err - } - resultArtifact := provider.Artifact{ - Path: filepath.ToSlash(filepath.Join("artifacts", destinationRelative)), - SHA256: hashValue, - } - for index := range result.Evidence { - result.Evidence[index].Artifacts = append(result.Evidence[index].Artifacts, resultArtifact) - } - } - } - return nil -} - -func copyArtifact(source, destination string) (string, error) { - input, err := openFile(source) - if err != nil { - return "", err - } - defer input.Close() - if err := makeDirectories(filepath.Dir(destination), 0o755); err != nil { - return "", err - } - temporary, err := createTemporary(filepath.Dir(destination), ".artifact-*") - if err != nil { - return "", err - } - name := temporary.Name() - defer os.Remove(name) - hasher := sha256.New() - if _, err := io.Copy(io.MultiWriter(temporary, hasher), input); err != nil { - temporary.Close() - return "", err - } - if err := temporary.Close(); err != nil { - return "", err - } - if err := renamePath(name, destination); err != nil { - return "", err - } - return hex.EncodeToString(hasher.Sum(nil)), nil -} - -func safeSegment(value string) string { - value = filepath.Base(filepath.Clean(value)) - if value == "." || value == ".." || value == string(filepath.Separator) { - return "artifact" - } - return value -} - -func skippedResult(current job, reason string) provider.Result { - return provider.Result{ - Provider: current.spec.Provider, Status: "skipped", Diagnostics: []string{reason}, - Evidence: []provider.Evidence{{ - ID: current.key, Class: "deterministic", Summary: reason, - }}, - } -} - -func resultFailed(result provider.Result) bool { - if result.Status != "completed" { - return true - } - for _, evidence := range result.Evidence { - if evidence.Data != nil && evidence.Data["retry_superseded"] == true { - continue - } - if evidence.Passed == nil || !*evidence.Passed { - return true - } - } - return len(result.Evidence) == 0 -} - -func cacheSuccess(result provider.Result) bool { - if result.Status != "completed" || len(result.Evidence) == 0 { - return false - } - for _, evidence := range result.Evidence { - if evidence.Data != nil && evidence.Data["retry_superseded"] == true { - continue - } - if evidence.Class != "deterministic" || evidence.Passed == nil || !*evidence.Passed { - return false - } - } - return true -} - -func cacheKey(request provider.Request, current job, providerVersion string, opt Options) (string, bool) { - inputHash, err := hashInputs(opt.Root, current.spec.Inputs, opt.ChangedFiles, opt.HeadCommit, opt.DiffHash) - if err != nil { - return "", false - } - raw, err := json.Marshal(struct { - Head, Diff, Requirement, Obligation, Plan, ProviderVersion, Environment, Inputs, Timeout string - Spec ir.ProviderSpec - }{ - Head: opt.HeadCommit, Diff: opt.DiffHash, - Requirement: current.req.Hash, Obligation: current.obl.Hash, - Plan: firstNonEmpty(opt.PlanHash, opt.IRHash), ProviderVersion: providerVersion, - Environment: provider.EnvironmentFingerprint(request), Inputs: inputHash, - Timeout: request.Timeout.String(), Spec: request.Spec, - }) - if err != nil { - return "", false - } - return hash(raw), true -} - -func hashInputs(root string, patterns, changed []string, head, diff string) (string, error) { - if len(patterns) == 0 { - patterns = append([]string{}, changed...) - if len(patterns) == 0 { - return hash([]byte(head + "\x00" + diff)), nil - } - } - var records []string - for _, pattern := range patterns { - matches, err := doublestar.FilepathGlob(filepath.Join(root, filepath.FromSlash(pattern))) - if err != nil { - return "", err - } - if len(matches) == 0 { - records = append(records, pattern+"\x00missing") - } - for _, path := range matches { - info, err := lstatPath(path) - if err != nil { - return "", err - } - var content []byte - if info.Mode()&os.ModeSymlink != 0 { - target, err := readlinkPath(path) - if err != nil { - return "", err - } - content = []byte("symlink:" + target) - } else if !info.IsDir() { - content, err = readPath(path) - if err != nil { - return "", err - } - } - relative, _ := filepath.Rel(root, path) - records = append(records, filepath.ToSlash(relative)+"\x00"+info.Mode().String()+"\x00"+hash(content)) - } - } - sort.Strings(records) - raw, _ := json.Marshal(records) - return hash(raw), nil -} - -func loadCache(directory, key string) (provider.Result, bool) { - if directory == "" { - return provider.Result{}, false - } - raw, err := readPath(filepath.Join(directory, key+".json")) - if err != nil { - return provider.Result{}, false - } - var result provider.Result - if json.Unmarshal(raw, &result) != nil || !cacheSuccess(result) { - return provider.Result{}, false - } - return result, true -} - -func saveCache(directory, key string, result provider.Result) error { - if directory == "" { - return nil - } - if err := makeDirectories(directory, 0o755); err != nil { - return err - } - raw, err := json.Marshal(result) - if err != nil { - return err - } - temporary, err := createTemporary(directory, ".cache-*") - if err != nil { - return err - } - name := temporary.Name() - defer os.Remove(name) - if _, err := temporary.Write(raw); err != nil { - temporary.Close() - return err - } - if err := temporary.Close(); err != nil { - return err - } - return renamePath(name, filepath.Join(directory, key+".json")) -} - -func hash(raw []byte) string { - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:]) -} - -// assignLeafIDs returns a copy of n with unique provider leaf IDs filled in. -func assignLeafIDs(n ir.VerifyNode, counter *int) ir.VerifyNode { - out := n - if n.Provider != nil { - spec := *n.Provider - if spec.ID == "" { - *counter++ - spec.ID = fmt.Sprintf("%s#%d", spec.Provider, *counter) - } - out.Provider = &spec - } - if len(n.All) > 0 { - out.All = make([]ir.VerifyNode, len(n.All)) - for index, child := range n.All { - out.All[index] = assignLeafIDs(child, counter) - } - } - if len(n.Any) > 0 { - out.Any = make([]ir.VerifyNode, len(n.Any)) - for index, child := range n.Any { - out.Any[index] = assignLeafIDs(child, counter) - } - } - if n.Not != nil { - child := assignLeafIDs(*n.Not, counter) - out.Not = &child - } - return out -} - -func collectLeaves(n ir.VerifyNode, fn func(ir.ProviderSpec)) { - if n.Provider != nil { - fn(*n.Provider) - } - for _, child := range n.All { - collectLeaves(child, fn) - } - for _, child := range n.Any { - collectLeaves(child, fn) - } - if n.Not != nil { - collectLeaves(*n.Not, fn) - } -} - -func split3(value string) []string { - var output []string - start := 0 - for index := 0; index < len(value); index++ { - if value[index] == '/' { - output = append(output, value[start:index]) - start = index + 1 - if len(output) == 2 { - output = append(output, value[start:]) - return output - } - } - } - return nil -} - -func contains(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} - -func boolPtr(value bool) *bool { return &value } - -// mutateResults is an optional test hook applied after provider execution. -var mutateResults func(map[string]provider.Result) diff --git a/internal/executor/executor_coverage_test.go b/internal/executor/executor_coverage_test.go deleted file mode 100644 index 11d117d..0000000 --- a/internal/executor/executor_coverage_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package executor_test - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/executor" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestRunUnknownCacheFailAndLogic(t *testing.T) { - cfg := config.Default() - cfg.Verification.DefaultTimeout = "2s" - cfg.Verification.MaxParallel = 2 - reqs := []ir.Requirement{{ - ID: "REQ-1", Status: "active", Priority: "required", Title: "t", - Obligations: []ir.Obligation{ - { - ID: "OBL-1", Required: true, Statement: "s", - Verify: ir.VerifyNode{Any: []ir.VerifyNode{ - {Provider: &ir.ProviderSpec{Provider: "unknown", ID: "u"}}, - {Not: &ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", ID: "c", Run: "false", Result: map[string]any{"equals": 0}}}}, - }}, - }, - { - ID: "OBL-2", Required: true, Statement: "fail", - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", Run: "false", Result: map[string]any{"equals": 0}}}, - }, - }, - }} - cache := t.TempDir() - // seed corrupt cache file to hit unmarshal miss - if err := os.WriteFile(filepath.Join(cache, "x.json"), []byte("{"), 0o644); err != nil { - t.Fatal(err) - } - leaves, results := executor.Run(context.Background(), reqs, executor.Options{ - Root: t.TempDir(), Config: cfg, RunID: "r", IRHash: "h", CacheDir: cache, NoCache: false, - }) - if len(results) != 1 { - t.Fatal(results) - } - if results[0].Verdict != verdict.Fail && results[0].Verdict != verdict.Error { - t.Fatalf("%+v", results[0]) - } - _ = leaves - - // empty cache dir / nil registry defaults - _, results = executor.Run(context.Background(), reqs[:1], executor.Options{ - Root: t.TempDir(), Config: cfg, Registry: nil, RunID: "r2", IRHash: "h2", NoCache: true, - }) - if len(results) != 1 { - t.Fatal(results) - } - - // saveCache mkdir fail: CacheDir is a file - file := filepath.Join(t.TempDir(), "file") - if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - passReqs := []ir.Requirement{{ - ID: "REQ-P", Priority: "required", - Obligations: []ir.Obligation{{ - ID: "O", Required: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", ID: "c", Run: "true", Result: map[string]any{"equals": 0}}}, - }}, - }} - _, _ = executor.Run(context.Background(), passReqs, executor.Options{ - Root: t.TempDir(), Config: cfg, RunID: "r3", IRHash: "h3", CacheDir: file, - }) - - // valid cache hit already covered; force loadCache bad json for matching key by writing after computing is hard. - // Write a completed failing result won't be cached; write passed then corrupt. - cache2 := t.TempDir() - _, _ = executor.Run(context.Background(), passReqs, executor.Options{ - Root: t.TempDir(), Config: cfg, RunID: "r4", IRHash: "same", CacheDir: cache2, - }) - entries, _ := os.ReadDir(cache2) - for _, e := range entries { - _ = os.WriteFile(filepath.Join(cache2, e.Name()), []byte("not-json"), 0o644) - } - _, results = executor.Run(context.Background(), passReqs, executor.Options{ - Root: t.TempDir(), Config: cfg, RunID: "r5", IRHash: "same", CacheDir: cache2, - }) - if results[0].Verdict != verdict.Pass { - t.Fatal(results) - } - - // ensure json marshal of cache works for empty evidence edge: status completed but not all passed skips cache - raw, _ := json.Marshal(provider.Result{Status: "completed"}) - _ = raw -} diff --git a/internal/executor/executor_internal_test.go b/internal/executor/executor_internal_test.go deleted file mode 100644 index 663b484..0000000 --- a/internal/executor/executor_internal_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package executor - -import ( - "context" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" -) - -func TestHelpers(t *testing.T) { - if split3("a/b") != nil { - t.Fatal("need 2 slashes") - } - if got := split3("a/b/c/d"); len(got) != 3 || got[2] != "c/d" { - t.Fatalf("%v", got) - } - var specs []string - collectLeaves(ir.VerifyNode{ - All: []ir.VerifyNode{{Provider: &ir.ProviderSpec{ID: "a"}}}, - Any: []ir.VerifyNode{{Provider: &ir.ProviderSpec{ID: "b"}}}, - Not: &ir.VerifyNode{Provider: &ir.ProviderSpec{ID: "c"}}, - }, func(s ir.ProviderSpec) { specs = append(specs, s.ID) }) - if len(specs) != 3 { - t.Fatal(specs) - } - if boolPtr(true) == nil || !*boolPtr(true) { - t.Fatal("boolPtr") - } -} - -func TestMutateResultsAndSaveCacheMarshal(t *testing.T) { - cfg := config.Default() - cfg.Verification.DefaultTimeout = "2s" - old := mutateResults - defer func() { mutateResults = old }() - mutateResults = func(m map[string]provider.Result) { - m["bad"] = provider.Result{} - } - reqs := []ir.Requirement{{ - ID: "REQ-1", Priority: "required", - Obligations: []ir.Obligation{{ - ID: "O", Required: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", ID: "c", Run: "true", Result: map[string]any{"equals": 0}}}, - }}, - }} - _, results := Run(context.Background(), reqs, Options{ - Root: t.TempDir(), Config: cfg, RunID: "r", IRHash: "h", NoCache: true, - }) - if len(results) != 1 { - t.Fatal(results) - } - - // empty leaves path when mutate clears all and no jobs - use req with no obligations - mutateResults = nil - _, results = Run(context.Background(), []ir.Requirement{{ID: "R", Priority: "required"}}, Options{ - Root: t.TempDir(), Config: cfg, RunID: "r2", IRHash: "h2", NoCache: true, - }) - if len(results) != 1 { - t.Fatal(results) - } -} diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go deleted file mode 100644 index 5590892..0000000 --- a/internal/executor/executor_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package executor_test - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/executor" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestRunCommand(t *testing.T) { - cfg := config.Default() - cfg.Verification.DefaultTimeout = "5s" - reqs := []ir.Requirement{{ - ID: "REQ-1", Status: "active", Priority: "required", Title: "t", - Obligations: []ir.Obligation{{ - ID: "OBL-1", Required: true, Statement: "s", - Verify: ir.VerifyNode{All: []ir.VerifyNode{{Provider: &ir.ProviderSpec{ - Provider: "command", ID: "c", Run: "true", Result: map[string]any{"equals": float64(0)}, - }}}}, - }}, - }} - cache := t.TempDir() - _, results := executor.Run(context.Background(), reqs, executor.Options{ - Root: t.TempDir(), Config: cfg, Registry: provider.DefaultRegistry(), - RunID: "r1", IRHash: "h", CacheDir: cache, - }) - if len(results) != 1 || results[0].Verdict != verdict.Pass { - t.Fatalf("%+v", results) - } - // cache hit - _, results = executor.Run(context.Background(), reqs, executor.Options{ - Root: t.TempDir(), Config: cfg, Registry: provider.DefaultRegistry(), - RunID: "r2", IRHash: "h", CacheDir: cache, - }) - if results[0].Verdict != verdict.Pass { - t.Fatal(results) - } -} - -func TestDuplicateAnonymousCommandLeavesDoNotFalsePass(t *testing.T) { - cfg := config.Default() - cfg.Verification.DefaultTimeout = "5s" - // Two command leaves without ids: one passes, one fails. Must be FAIL every time. - reqs := []ir.Requirement{{ - ID: "REQ-1", Status: "active", Priority: "required", Title: "t", - Obligations: []ir.Obligation{{ - ID: "OBL-1", Required: true, Statement: "s", - Verify: ir.VerifyNode{All: []ir.VerifyNode{ - {Provider: &ir.ProviderSpec{Provider: "command", Run: "true", Result: map[string]any{"equals": float64(0)}}}, - {Provider: &ir.ProviderSpec{Provider: "command", Run: "false", Result: map[string]any{"equals": float64(0)}}}, - }}, - }}, - }} - for i := 0; i < 20; i++ { - _, results := executor.Run(context.Background(), reqs, executor.Options{ - Root: t.TempDir(), Config: cfg, Registry: provider.DefaultRegistry(), - RunID: "r", IRHash: "h", NoCache: true, - }) - if len(results) != 1 || results[0].Verdict != verdict.Fail { - t.Fatalf("iter %d: %+v", i, results) - } - } -} - -func TestCacheDoesNotReuseSamePathAfterContentChanges(t *testing.T) { - root := t.TempDir() - script := filepath.Join(root, "check.sh") - if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - cfg := config.Default() - reqs := []ir.Requirement{{ - ID: "REQ-1", Status: "active", Priority: "required", - Obligations: []ir.Obligation{{ - ID: "OBL-1", Required: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", ID: "check", Run: "./check.sh"}}, - }}, - }} - opt := executor.Options{ - Root: root, Config: cfg, Registry: provider.DefaultRegistry(), RunID: "run", - IRHash: "same", ChangedFiles: []string{"check.sh"}, CacheDir: filepath.Join(root, "cache"), - } - _, first := executor.Run(context.Background(), reqs, opt) - if first[0].Verdict != verdict.Pass { - t.Fatal(first) - } - if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { - t.Fatal(err) - } - _, second := executor.Run(context.Background(), reqs, opt) - if second[0].Verdict != verdict.Fail { - t.Fatalf("stale cache produced %s", second[0].Verdict) - } -} diff --git a/internal/executor/executor_v1_edges_test.go b/internal/executor/executor_v1_edges_test.go deleted file mode 100644 index 51f6b01..0000000 --- a/internal/executor/executor_v1_edges_test.go +++ /dev/null @@ -1,549 +0,0 @@ -package executor - -import ( - "context" - "errors" - "io" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/security" - "github.com/hypertrial/intentci/internal/verdict" -) - -type staticProvider struct { - name string - version string - result provider.Result - execute func(context.Context, provider.Request) provider.Result -} - -func (p *staticProvider) Name() string { return p.name } -func (p *staticProvider) Version() string { return p.version } -func (p *staticProvider) Validate(ir.ProviderSpec) []provider.Diagnostic { - return nil -} -func (p *staticProvider) Execute(ctx context.Context, request provider.Request) provider.Result { - if p.execute != nil { - return p.execute(ctx, request) - } - return p.result -} - -func edgeJob(id string) job { - return job{ - req: ir.Requirement{ID: "REQ", Hash: "requirement"}, - obl: ir.Obligation{ID: "OBL", Hash: "obligation", Required: true}, - key: id, fullKey: "REQ/OBL/" + id, - spec: ir.ProviderSpec{Provider: "static", ID: id}, - } -} - -func TestRunDependenciesAndDefaultOptions(t *testing.T) { - _, empty := Run(context.Background(), nil, Options{Root: t.TempDir(), NoCache: true}) - if len(empty) != 0 { - t.Fatal(empty) - } - failed, passed := false, true - implementation := &staticProvider{ - name: "static", version: "1", - result: provider.Result{Status: "completed", Evidence: []provider.Evidence{{ - Class: "deterministic", Passed: &passed, - }}}, - } - registry := provider.NewRegistry(implementation) - requirements := []ir.Requirement{ - { - ID: "BASE", Priority: "required", Obligations: []ir.Obligation{{ - ID: "FAIL", Required: true, Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "static", ID: "base", - }}, - }}, - }, - { - ID: "DEPENDENT", Priority: "required", DependsOn: []string{"BASE"}, - Obligations: []ir.Obligation{ - {ID: "FIRST", Required: true, Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "static", ID: "first"}}}, - {ID: "SECOND", Required: true, DependsOn: []string{"FIRST"}, Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "static", ID: "second", DependsOn: []string{"first"}, - }}}, - }, - }, - } - implementation.execute = func(_ context.Context, request provider.Request) provider.Result { - value := &passed - if request.RequirementID == "BASE" || request.ObligationID == "FIRST" { - value = &failed - } - return provider.Result{Status: "completed", Evidence: []provider.Evidence{{Class: "deterministic", Passed: value}}} - } - _, results := Run(context.Background(), requirements, Options{ - Root: t.TempDir(), Config: config.Default(), Registry: registry, NoCache: true, - }) - if results[0].Verdict != verdict.Fail || results[1].Verdict != verdict.Fail || - results[1].Obligations[1].Verdict != verdict.Unproven { - t.Fatalf("%+v", results) - } - - direct := applyRequirementDependencies( - []ir.Requirement{{ID: "A", DependsOn: []string{"missing"}}, {ID: "B", DependsOn: []string{"A"}}}, - []verdict.RequirementResult{{ID: "A", Verdict: verdict.Pass}, {ID: "B", Verdict: verdict.Pass}}, - ) - if direct[0].Verdict != verdict.Unproven || direct[1].Verdict != verdict.Unproven { - t.Fatalf("%+v", direct) - } -} - -func TestNormalizeResultsAllStatuses(t *testing.T) { - passed, failed := true, false - confidence := 2.0 - implementation := &staticProvider{name: "static", version: "1"} - registry := provider.NewRegistry(implementation) - jobs := []job{} - results := map[string]provider.Result{"unknown": {}} - add := func(id, status string, evidence provider.Evidence) { - current := edgeJob(id) - jobs = append(jobs, current) - results[current.fullKey] = provider.Result{Status: status, Evidence: []provider.Evidence{evidence}} - } - add("invalid-status", "bogus", provider.Evidence{Class: "deterministic", Passed: &passed}) - add("invalid-class", "completed", provider.Evidence{Class: "bogus", Passed: &passed}) - add("invalid-confidence", "completed", provider.Evidence{Class: "deterministic", Confidence: &confidence, Passed: &passed}) - add("error", "error", provider.Evidence{Class: "deterministic"}) - add("skipped", "skipped", provider.Evidence{Class: "deterministic"}) - add("unknown-status", "completed", provider.Evidence{Class: "deterministic"}) - add("passed", "completed", provider.Evidence{Class: "deterministic", Passed: &passed}) - add("failed", "completed", provider.Evidence{Class: "deterministic", Passed: &failed}) - normalizeResults(results, jobs, Options{Registry: registry, RunID: "run"}) - for _, id := range []string{"invalid-status", "invalid-class", "invalid-confidence"} { - if results["REQ/OBL/"+id].Status != "error" { - t.Fatalf("%s: %+v", id, results["REQ/OBL/"+id]) - } - } - for id, want := range map[string]string{ - "error": "error", "skipped": "skipped", "unknown-status": "unknown", - "passed": "passed", "failed": "failed", - } { - record := results["REQ/OBL/"+id].Evidence[0] - if record.ID != id || record.Status != want || record.ProviderVersion != "1" || - record.StartedAt.IsZero() || record.CompletedAt.IsZero() { - t.Fatalf("%s: %+v", id, record) - } - } -} - -func TestExecuteJobsDependencyFailFastAndDirectStops(t *testing.T) { - cfg := config.Default() - cfg.Verification.MaxParallel = 1 - passed, failed := true, false - implementation := &staticProvider{ - name: "static", version: "1", - result: provider.Result{Status: "completed", Evidence: []provider.Evidence{{ - Class: "deterministic", Passed: &passed, - }}}, - } - opt := Options{ - Root: t.TempDir(), Config: cfg, Registry: provider.NewRegistry(implementation), NoCache: true, - } - blocked := edgeJob("blocked") - blocked.dependsOn = []string{"missing"} - if result := executeJobs(context.Background(), []job{blocked}, opt)[blocked.fullKey]; result.Status != "skipped" { - t.Fatal(result) - } - - cfg.Verification.FailFast = true - first, second := edgeJob("a"), edgeJob("b") - implementation.result = provider.Result{Status: "completed", Evidence: []provider.Evidence{{ - Class: "deterministic", Passed: &failed, - }}} - results := executeJobs(context.Background(), []job{second, first}, opt) - if results[second.fullKey].Status != "skipped" { - t.Fatalf("%+v", results) - } - - if batch := compatibleBatch([]job{first}, 0); len(batch) != 1 { - t.Fatal(batch) - } - cancelled, cancel := context.WithCancel(context.Background()) - cancel() - if result := executeJob(cancelled, first, opt); result.Status != "error" { - t.Fatal(result) - } - other := "linux" - if runtime.GOOS == "linux" { - other = "darwin" - } - first.obl.Platforms = []string{other} - if result := executeJob(context.Background(), first, opt); result.Status != "skipped" { - t.Fatal(result) - } -} - -func TestRetryBackoffCancellationAndArtifactFailure(t *testing.T) { - failed := false - cfg := config.Default() - current := edgeJob("retry") - current.spec.Retry = ir.Retry{Attempts: 2, Backoff: "1ms"} - implementation := &staticProvider{ - name: "static", version: "1", - result: provider.Result{ - Status: "completed", Stdout: "line\n", - Evidence: []provider.Evidence{{Class: "deterministic", Passed: &failed}}, - }, - } - opt := Options{ - Root: t.TempDir(), Config: cfg, Registry: provider.NewRegistry(implementation), NoCache: true, - } - if result := executeJob(context.Background(), current, opt); len(result.Evidence) != 2 { - t.Fatal(result) - } - - ctx, cancel := context.WithCancel(context.Background()) - current.spec.Retry.Backoff = time.Second.String() - implementation.execute = func(context.Context, provider.Request) provider.Result { - go func() { - time.Sleep(10 * time.Millisecond) - cancel() - }() - return implementation.result - } - if result := executeJob(ctx, current, opt); len(result.Evidence) == 0 { - t.Fatal(result) - } - - implementation.execute = nil - current.spec.Retry = ir.Retry{} - current.spec.Artifacts = []string{"["} - current.spec.Outputs = []string{"missing"} - current.spec.Exclusive = true - result := executeJob(context.Background(), current, opt) - if result.Status != "error" || len(result.Diagnostics) == 0 { - t.Fatal(result) - } -} - -func TestEvidenceOutputRedactionAndCacheHelpers(t *testing.T) { - passed, failed := true, false - implementation := &staticProvider{name: "static", version: "1"} - threshold := 0.8 - for _, testCase := range []struct { - status string - pass *bool - want string - }{ - {"error", &failed, "error"}, - {"skipped", nil, "skipped"}, - {"completed", nil, "unknown"}, - {"completed", &passed, "passed"}, - {"completed", &failed, "failed"}, - } { - result := provider.Result{Status: testCase.status, Evidence: []provider.Evidence{{}}} - result.Evidence[0].Passed = testCase.pass - enrichEvidence(&result, provider.Request{ - Spec: ir.ProviderSpec{Provider: "static", ID: "id"}, - EvidenceClass: "probabilistic", ConfidenceThreshold: &threshold, - }, implementation) - if result.Evidence[0].Status != testCase.want || - result.Evidence[0].Class != "probabilistic" || - result.Evidence[0].Confidence != nil { - t.Fatalf("%+v", result) - } - node := ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "static", ID: "id"}} - got, _, _ := verdict.EvaluateNodeWithPolicy( - node, - map[string]provider.Result{"id": result}, - verdict.EvidencePolicy{Class: "probabilistic", ConfidenceThreshold: &threshold}, - ) - if got == verdict.Pass { - t.Fatalf("missing observed confidence must not pass: %+v", result) - } - } - resultWithVersion := provider.Result{ - Status: "completed", ProviderVersion: "custom", - Evidence: []provider.Evidence{{Class: "deterministic", Passed: &passed}}, - } - enrichEvidence(&resultWithVersion, provider.Request{}, implementation) - if resultWithVersion.Evidence[0].ProviderVersion != "custom" { - t.Fatal(resultWithVersion) - } - - outputResult := provider.Result{Status: "error"} - validateOutputs(provider.Request{Spec: ir.ProviderSpec{Outputs: []string{"missing"}}}, &outputResult) - if len(outputResult.Evidence) != 0 { - t.Fatal(outputResult) - } - var log strings.Builder - appendAttemptLog(&log, 1, "line\n") - if strings.Count(log.String(), "\n") != 2 { - t.Fatal(log.String()) - } - t.Setenv("SECRET_VALUE", "true") - redacted := redactResult(provider.Result{ - Status: "completed", Stdout: "true", Extra: map[string]any{"flag": true, "items": []any{"true"}}, - }, []string{"SECRET_VALUE"}) - if redacted.Stdout != "[REDACTED]" || redacted.Extra["flag"] != true { - t.Fatalf("%+v", redacted) - } - if effectiveTimeout("bad", "") != 10*time.Minute { - t.Fatal("default timeout") - } - if attempts, _ := retrySettings(ir.Retry{}, ir.Retry{}); attempts != 1 { - t.Fatal(attempts) - } - if resultFailed(provider.Result{ - Status: "completed", Evidence: []provider.Evidence{ - {Data: map[string]any{"retry_superseded": true}}, - {Class: "deterministic", Passed: &passed}, - }, - }) { - t.Fatal("superseded retry failed result") - } - if !cacheSuccess(provider.Result{ - Status: "completed", Evidence: []provider.Evidence{ - {Data: map[string]any{"retry_superseded": true}}, - {Class: "deterministic", Passed: &passed}, - }, - }) { - t.Fatal("superseded retry invalidated cache") - } - for _, value := range []provider.Result{ - {}, - {Status: "completed"}, - {Status: "completed", Evidence: []provider.Evidence{{Class: "probabilistic", Passed: &passed}}}, - } { - if cacheSuccess(value) { - t.Fatal(value) - } - } -} - -func TestArtifactCollectionEdges(t *testing.T) { - root := t.TempDir() - evidenceRoot := filepath.Join(root, "evidence") - if err := os.WriteFile(filepath.Join(root, "file"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(root, "directory"), 0o755); err != nil { - t.Fatal(err) - } - request := provider.Request{ - Root: root, EvidenceDir: evidenceRoot, RequirementID: "../REQ", ObligationID: "../OBL", - ExecutionAttempt: 2, - Spec: ir.ProviderSpec{Provider: "static", ID: "../id", Artifacts: []string{"file"}}, - } - result := provider.Result{Evidence: []provider.Evidence{{}}} - if err := collectArtifacts(request, &result); err != nil || - !strings.Contains(result.Evidence[0].Artifacts[0].Path, "try-002") { - t.Fatalf("%+v %v", result, err) - } - request.Spec.Artifacts = []string{"directory"} - if err := collectArtifacts(request, &result); err == nil { - t.Fatal("directory collected as artifact") - } - request.Spec.Artifacts = []string{"["} - if err := collectArtifacts(request, &result); err == nil { - t.Fatal("invalid artifact glob accepted") - } - request.Spec.Artifacts = []string{"file"} - request.EvidenceDir = filepath.Join(root, "evidence-file") - if err := os.WriteFile(request.EvidenceDir, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := collectArtifacts(request, &result); err == nil { - t.Fatal("evidence directory file accepted") - } - request.EvidenceDir = evidenceRoot - oldLstat, oldOpen := lstatPath, openFile - lstatPath = func(string) (os.FileInfo, error) { return nil, errors.New("lstat") } - if err := collectArtifacts(request, &result); err == nil { - t.Fatal("artifact lstat failure ignored") - } - lstatPath = oldLstat - openFile = func(string) (*os.File, error) { return nil, errors.New("open") } - if err := collectArtifacts(request, &result); err == nil { - t.Fatal("artifact copy failure ignored") - } - openFile = oldOpen - - outside := t.TempDir() - if err := os.Symlink(outside, filepath.Join(root, "outside-link")); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(outside, "artifact"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - request.EvidenceDir = evidenceRoot - request.Spec.Artifacts = []string{"outside-link/*"} - if err := collectArtifacts(request, &result); err == nil || !security.IsPathViolation(errors.Unwrap(err)) && !strings.Contains(err.Error(), "symlink") { - t.Fatalf("%v", err) - } - - destinationLink := filepath.Join(evidenceRoot, "REQ") - if err := os.MkdirAll(evidenceRoot, 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, destinationLink); err != nil && !os.IsExist(err) { - t.Fatal(err) - } - request.RequirementID = "REQ" - request.ExecutionAttempt = 1 - request.Spec.Artifacts = []string{"file"} - if err := collectArtifacts(request, &result); err == nil { - t.Fatal("artifact destination symlink accepted") - } -} - -type failingTemporary struct { - name string - writeError error - closeError error -} - -func (f *failingTemporary) Name() string { return f.name } -func (f *failingTemporary) Close() error { return f.closeError } -func (f *failingTemporary) Write(value []byte) (int, error) { - if f.writeError != nil { - return 0, f.writeError - } - return len(value), nil -} - -func TestFilesystemAndCacheFailures(t *testing.T) { - root := t.TempDir() - source := filepath.Join(root, "source") - if err := os.WriteFile(source, []byte("source"), 0o644); err != nil { - t.Fatal(err) - } - oldMkdir, oldCreate, oldRename := makeDirectories, createTemporary, renamePath - oldLstat, oldReadlink, oldRead := lstatPath, readlinkPath, readPath - defer func() { - makeDirectories, createTemporary, renamePath = oldMkdir, oldCreate, oldRename - lstatPath, readlinkPath, readPath = oldLstat, oldReadlink, oldRead - }() - - makeDirectories = func(string, os.FileMode) error { return errors.New("mkdir") } - if _, err := copyArtifact(source, filepath.Join(root, "out")); err == nil { - t.Fatal("artifact mkdir failure ignored") - } - makeDirectories = oldMkdir - createTemporary = func(string, string) (temporaryFile, error) { return nil, errors.New("create") } - if _, err := copyArtifact(source, filepath.Join(root, "out")); err == nil { - t.Fatal("artifact temp failure ignored") - } - createTemporary = func(string, string) (temporaryFile, error) { - return &failingTemporary{name: filepath.Join(root, "tmp"), writeError: errors.New("write")}, nil - } - if _, err := copyArtifact(source, filepath.Join(root, "out")); err == nil { - t.Fatal("artifact write failure ignored") - } - createTemporary = func(string, string) (temporaryFile, error) { - return &failingTemporary{name: filepath.Join(root, "tmp"), closeError: errors.New("close")}, nil - } - if _, err := copyArtifact(source, filepath.Join(root, "out")); err == nil { - t.Fatal("artifact close failure ignored") - } - createTemporary = oldCreate - renamePath = func(string, string) error { return errors.New("rename") } - if _, err := copyArtifact(source, filepath.Join(root, "out")); err == nil { - t.Fatal("artifact rename failure ignored") - } - renamePath = oldRename - - current := edgeJob("cache") - current.spec.Inputs = []string{"["} - if _, ok := cacheKey(provider.Request{}, current, "1", Options{Root: root}); ok { - t.Fatal("invalid input glob produced cache key") - } - current.spec.Inputs = nil - current.spec.Extra = map[string]any{"bad": make(chan int)} - if _, ok := cacheKey(provider.Request{Spec: current.spec}, current, "1", Options{Root: root}); ok { - t.Fatal("unmarshalable provider spec produced cache key") - } - - directory := filepath.Join(root, "directory") - link := filepath.Join(root, "link") - if err := os.Mkdir(directory, 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink("source", link); err != nil { - t.Fatal(err) - } - if _, err := hashInputs(root, []string{"directory", "link", "source"}, nil, "", ""); err != nil { - t.Fatal(err) - } - lstatPath = func(string) (os.FileInfo, error) { return nil, errors.New("lstat") } - if _, err := hashInputs(root, []string{"source"}, nil, "", ""); err == nil { - t.Fatal("lstat failure ignored") - } - lstatPath = oldLstat - readlinkPath = func(string) (string, error) { return "", errors.New("readlink") } - if _, err := hashInputs(root, []string{"link"}, nil, "", ""); err == nil { - t.Fatal("readlink failure ignored") - } - readlinkPath = oldReadlink - readPath = func(string) ([]byte, error) { return nil, errors.New("read") } - if _, err := hashInputs(root, []string{"source"}, nil, "", ""); err == nil { - t.Fatal("read failure ignored") - } - readPath = oldRead - - if err := saveCache(root, "bad", provider.Result{Extra: map[string]any{"bad": make(chan int)}}); err == nil { - t.Fatal("cache marshal failure ignored") - } - cacheFile := filepath.Join(root, "cache-file") - if err := os.WriteFile(cacheFile, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := saveCache(cacheFile, "key", provider.Result{}); err == nil { - t.Fatal("cache mkdir failure ignored") - } - createTemporary = func(string, string) (temporaryFile, error) { return nil, errors.New("create") } - if err := saveCache(root, "key", provider.Result{}); err == nil { - t.Fatal("cache create failure ignored") - } - createTemporary = func(string, string) (temporaryFile, error) { - return &failingTemporary{name: filepath.Join(root, "tmp"), writeError: errors.New("write")}, nil - } - if err := saveCache(root, "key", provider.Result{}); err == nil { - t.Fatal("cache write failure ignored") - } - createTemporary = func(string, string) (temporaryFile, error) { - return &failingTemporary{name: filepath.Join(root, "tmp"), closeError: errors.New("close")}, nil - } - if err := saveCache(root, "key", provider.Result{}); err == nil { - t.Fatal("cache close failure ignored") - } - createTemporary = oldCreate - renamePath = func(string, string) error { return errors.New("rename") } - if err := saveCache(root, "key", provider.Result{}); err == nil { - t.Fatal("cache rename failure ignored") - } -} - -func TestRemainingHelpers(t *testing.T) { - if !outputPatternsConflict("build/file", "build/**") || - !outputPatternsConflict("build/file", "build/file?") || - wildcardIndex("plain") != len("plain") || - wildcardIndex("a[b]") != 1 { - t.Fatal("pattern helpers") - } - if strings.Join(jobWritePatterns(ir.ProviderSpec{Outputs: []string{"a"}, Artifacts: []string{"b"}}), "") != "ab" { - t.Fatal("write patterns") - } - if contains([]string{"a"}, "b") || firstNonEmpty("", "") != "" { - t.Fatal("list helpers") - } - if safeSegment(".") != "artifact" || safeSegment("/") != "artifact" { - t.Fatal("safe segments") - } - if _, err := io.Copy(io.Discard, strings.NewReader("x")); err != nil { - t.Fatal(err) - } -} diff --git a/internal/executor/executor_v1_internal_test.go b/internal/executor/executor_v1_internal_test.go deleted file mode 100644 index 1334f6a..0000000 --- a/internal/executor/executor_v1_internal_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package executor - -import ( - "context" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -type sequenceProvider struct { - mutex sync.Mutex - results []provider.Result - calls int -} - -func (p *sequenceProvider) Name() string { return "sequence" } -func (p *sequenceProvider) Version() string { return "1.2.3" } -func (p *sequenceProvider) Validate(ir.ProviderSpec) []provider.Diagnostic { return nil } -func (p *sequenceProvider) Execute(context.Context, provider.Request) provider.Result { - p.mutex.Lock() - defer p.mutex.Unlock() - index := p.calls - p.calls++ - if index >= len(p.results) { - index = len(p.results) - 1 - } - return p.results[index] -} - -func TestSchedulerRetryDependenciesAndManualReview(t *testing.T) { - failed := false - passed := true - implementation := &sequenceProvider{results: []provider.Result{ - {Provider: "sequence", ProviderVersion: "1.2.3", Status: "error", Stdout: "first", Stderr: "bad", Evidence: []provider.Evidence{{ID: "e", Class: "deterministic", Summary: "retry", Passed: &failed}}}, - {Provider: "sequence", ProviderVersion: "1.2.3", Status: "completed", Stdout: "second", Evidence: []provider.Evidence{{ID: "e", Class: "deterministic", Summary: "ok", Passed: &passed}}}, - }} - registry := provider.NewRegistry(implementation) - requirements := []ir.Requirement{{ - ID: "REQ", Priority: "required", Hash: strings.Repeat("a", 64), - Obligations: []ir.Obligation{ - { - ID: "FIRST", Required: true, Hash: strings.Repeat("b", 64), - Retry: ir.Retry{Attempts: 2}, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "sequence", ID: "retry"}}, - }, - { - ID: "SECOND", Required: true, DependsOn: []string{"FIRST"}, - Hash: strings.Repeat("c", 64), ManualReview: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "sequence", ID: "review"}}, - }, - }, - }} - leaves, results := Run(context.Background(), requirements, Options{ - Root: t.TempDir(), Config: config.Default(), Registry: registry, - RunID: "run", AttemptID: "attempt-001", HeadCommit: "head", BaseCommit: "base", - DiffHash: "diff", IRHash: "ir", PlanHash: "plan", NoCache: true, - }) - if implementation.calls != 3 || results[0].Verdict != verdict.ReviewRequired { - t.Fatalf("calls=%d results=%+v", implementation.calls, results) - } - retry := leaves["REQ"]["FIRST/retry"] - if len(retry.Evidence) != 2 || !strings.Contains(retry.Stdout, "provider attempt 1") || - retry.Evidence[0].ID == retry.Evidence[1].ID { - t.Fatalf("%+v", retry) - } -} - -func TestSchedulerCancellationConflictsAndSelectors(t *testing.T) { - cancelled, cancel := context.WithCancel(context.Background()) - cancel() - requirement := ir.Requirement{ - ID: "REQ", Priority: "required", - Obligations: []ir.Obligation{{ - ID: "O", Required: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", ID: "c", Run: "true"}}, - }}, - } - _, results := Run(cancelled, []ir.Requirement{requirement}, Options{ - Root: t.TempDir(), Config: config.Default(), RunID: "run", NoCache: true, - }) - if results[0].Verdict != verdict.Error { - t.Fatal(results) - } - - if !outputPatternsConflict("build/**", "build/file") || - !outputPatternsConflict("same", "same") || - outputPatternsConflict("build/**", "dist/**") { - t.Fatal("output conflict detection") - } - jobs := []job{ - {fullKey: "a", spec: ir.ProviderSpec{Outputs: []string{"build/**"}}}, - {fullKey: "b", spec: ir.ProviderSpec{Outputs: []string{"build/file"}}}, - {fullKey: "c", spec: ir.ProviderSpec{Exclusive: true}}, - } - if batch := compatibleBatch(jobs, 3); len(batch) != 1 || batch[0].fullKey != "a" { - t.Fatalf("%+v", batch) - } - if batch := compatibleBatch(jobs[2:], 1); len(batch) != 1 || batch[0].fullKey != "c" { - t.Fatalf("%+v", batch) - } - - otherPlatform := "linux" - if runtime.GOOS == "linux" { - otherPlatform = "darwin" - } - requirement.Obligations[0].Platforms = []string{otherPlatform} - _, results = Run(context.Background(), []ir.Requirement{requirement}, Options{ - Root: t.TempDir(), Config: config.Default(), ProviderID: "other", RunID: "run", NoCache: true, - }) - if results[0].Verdict != verdict.Unproven { - t.Fatal(results) - } -} - -func TestArtifactsOutputsAndCacheReuse(t *testing.T) { - passed := true - root := t.TempDir() - evidenceDir := filepath.Join(root, ".intentci", "runs", "run", "attempts", "attempt-001", "artifacts") - requirement := ir.Requirement{ - ID: "REQ", Priority: "required", Hash: strings.Repeat("a", 64), - Obligations: []ir.Obligation{{ - ID: "O", Required: true, Hash: strings.Repeat("b", 64), - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "command", ID: "build", - Run: "mkdir -p build && printf artifact > build/out.txt && printf ok", - Result: map[string]any{"equals": 0, "stdout": map[string]any{"contains": "ok"}}, - Outputs: []string{"build/out.txt"}, Artifacts: []string{"build/*.txt"}, - }}, - }}, - } - leaves, results := Run(context.Background(), []ir.Requirement{requirement}, Options{ - Root: root, Config: config.Default(), RunID: "run", AttemptID: "attempt-001", - EvidenceDir: evidenceDir, HeadCommit: "head", BaseCommit: "base", DiffHash: "diff", - IRHash: "ir", PlanHash: "plan", CacheDir: filepath.Join(root, "cache"), - }) - record := leaves["REQ"]["O/build"].Evidence[0] - if results[0].Verdict != verdict.Pass || len(record.Artifacts) != 1 { - t.Fatalf("%+v %+v", results, record) - } - if _, err := os.Stat(filepath.Join(evidenceDir, "REQ", "O", "build", "build", "out.txt")); err != nil { - t.Fatal(err) - } - - requirement.Obligations[0].Verify.Provider.Outputs = []string{"missing.txt"} - requirement.Obligations[0].Verify.Provider.Artifacts = nil - _, results = Run(context.Background(), []ir.Requirement{requirement}, Options{ - Root: root, Config: config.Default(), RunID: "missing", NoCache: true, - }) - if results[0].Verdict != verdict.Fail { - t.Fatal(results) - } - - cacheProvider := &sequenceProvider{results: []provider.Result{{ - Provider: "sequence", ProviderVersion: "1.2.3", Status: "completed", - Evidence: []provider.Evidence{{ID: "cache", Class: "deterministic", Summary: "ok", Passed: &passed}}, - }}} - cacheRequirement := requirement - cacheRequirement.Obligations[0].Verify.Provider = &ir.ProviderSpec{Provider: "sequence", ID: "cache"} - cacheDir := filepath.Join(root, "cache-reuse") - options := Options{ - Root: root, Config: config.Default(), Registry: provider.NewRegistry(cacheProvider), - RunID: "one", AttemptID: "attempt-001", HeadCommit: "head", DiffHash: "diff", - IRHash: "ir", PlanHash: "plan", CacheDir: cacheDir, - } - _, _ = Run(context.Background(), []ir.Requirement{cacheRequirement}, options) - options.RunID = "two" - cached, cachedResults := Run(context.Background(), []ir.Requirement{cacheRequirement}, options) - got := cached["REQ"]["O/cache"] - if cacheProvider.calls != 1 || !got.FromCache || got.SourceEvidenceHash == "" || - cachedResults[0].Verdict != verdict.Pass { - t.Fatalf("calls=%d result=%+v verdict=%+v", cacheProvider.calls, got, cachedResults) - } -} - -func TestExecutorHelperFailures(t *testing.T) { - root := t.TempDir() - if _, err := hashInputs(root, []string{"missing/**"}, nil, "", ""); err != nil { - t.Fatal(err) - } - if _, ok := loadCache("", "x"); ok { - t.Fatal("empty cache hit") - } - if _, ok := loadCache(root, "missing"); ok { - t.Fatal("missing cache hit") - } - if err := saveCache("", "x", provider.Result{}); err != nil { - t.Fatal(err) - } - if safeSegment("../") != "artifact" || safeSegment("normal") != "normal" { - t.Fatal("safe segment") - } - var log strings.Builder - appendAttemptLog(&log, 1, "") - if log.Len() != 0 { - t.Fatal(log.String()) - } - if redactResult(provider.Result{Extra: map[string]any{"bad": make(chan int)}}, nil).Extra == nil { - t.Fatal("marshal failure should preserve result") - } - t.Setenv("TOKEN", "secret") - redacted := redactResult(provider.Result{Stdout: "secret"}, []string{"TOKEN"}) - if redacted.Stdout != "[REDACTED]" { - t.Fatal(redacted) - } - if _, err := copyArtifact(filepath.Join(root, "missing"), filepath.Join(root, "out")); err == nil { - t.Fatal("missing artifact source accepted") - } -} diff --git a/internal/exitcode/exitcode.go b/internal/exitcode/exitcode.go deleted file mode 100644 index 5f04d34..0000000 --- a/internal/exitcode/exitcode.go +++ /dev/null @@ -1,16 +0,0 @@ -package exitcode - -// Process exit codes from v1.md §17. -const ( - Pass = 0 - Fail = 1 - Unproven = 2 - Uncertain = 3 - ReviewRequired = 4 - CompileFailed = 5 - VerifierError = 6 - Internal = 7 - Usage = 8 - RepairExhausted = 9 - SecurityBoundary = 10 -) diff --git a/internal/exitcode/exitcode_test.go b/internal/exitcode/exitcode_test.go deleted file mode 100644 index bf8c1bc..0000000 --- a/internal/exitcode/exitcode_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package exitcode_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/exitcode" -) - -func TestConstants(t *testing.T) { - if exitcode.Pass != 0 || exitcode.Fail != 1 || exitcode.SecurityBoundary != 10 { - t.Fatal("unexpected exit codes") - } -} diff --git a/internal/git/git.go b/internal/git/git.go deleted file mode 100644 index b55e93a..0000000 --- a/internal/git/git.go +++ /dev/null @@ -1,380 +0,0 @@ -package git - -import ( - "bytes" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" -) - -// State describes the Git comparison for a verification run. -type State struct { - Root string `json:"root"` - BaseRef string `json:"base_ref"` - BaseCommit string `json:"base_commit"` - HeadCommit string `json:"head_commit"` - MergeBase string `json:"merge_base"` - MergeBaseFull string `json:"merge_base_full"` - ChangedFiles []string `json:"changed_files"` - WorkingTreeDirty bool `json:"working_tree_dirty"` - WorkingTreeFingerprint string `json:"working_tree_fingerprint"` - DiffHash string `json:"diff_hash"` - DiffPatch string `json:"-"` - Changes []Change `json:"changes"` -} - -// Change describes one changed repository path. -type Change struct { - Path string `json:"path"` - OldPath string `json:"old_path,omitempty"` - Status string `json:"status"` - Additions int `json:"additions,omitempty"` - Deletions int `json:"deletions,omitempty"` - Binary bool `json:"binary,omitempty"` - OldMode string `json:"old_mode,omitempty"` - NewMode string `json:"new_mode,omitempty"` -} - -// ResolveOptions configures a detailed repository comparison. -type ResolveOptions struct { - BaseRef string - HeadRef string - IncludeUntracked bool -} - -var run = runGit -var absPath = filepath.Abs - -var execCommand = exec.Command - -func runGit(root string, args ...string) (string, error) { - cmd := execCommand("git", args...) - cmd.Dir = root - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg == "" { - msg = err.Error() - } - return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) - } - return strings.TrimSpace(stdout.String()), nil -} - -// Resolve computes merge-base, head, dirty status, and changed files. -func Resolve(root, baseRef string) (*State, error) { - abs, err := absPath(root) - if err != nil { - return nil, err - } - if _, err := run(abs, "rev-parse", "--is-inside-work-tree"); err != nil { - return nil, fmt.Errorf("not a git repository: %s", abs) - } - head, err := run(abs, "rev-parse", "HEAD") - if err != nil { - return nil, fmt.Errorf("resolve HEAD: %w", err) - } - if baseRef == "" { - baseRef = "origin/main" - } - if _, err := run(abs, "rev-parse", "--verify", baseRef); err != nil { - return nil, fmt.Errorf("missing base reference %q: %w", baseRef, err) - } - baseCommit, err := run(abs, "rev-parse", baseRef) - if err != nil { - return nil, err - } - mergeBase, err := run(abs, "merge-base", baseCommit, head) - if err != nil { - return nil, fmt.Errorf("merge-base: %w", err) - } - diffOut, err := run(abs, "diff", "--name-only", mergeBase) - if err != nil { - return nil, err - } - var files []string - for _, line := range strings.Split(diffOut, "\n") { - line = strings.TrimSpace(line) - if line != "" { - files = append(files, filepath.ToSlash(line)) - } - } - // unstaged + untracked - status, err := run(abs, "status", "--porcelain") - if err != nil { - return nil, err - } - dirty := status != "" - for _, line := range strings.Split(status, "\n") { - if len(line) < 4 { - continue - } - path := strings.TrimSpace(line[3:]) - if path == "" { - continue - } - if i := strings.Index(path, " -> "); i >= 0 { - path = path[i+4:] - } - path = filepath.ToSlash(path) - if !contains(files, path) { - files = append(files, path) - } - } - shortMB := mergeBase - if len(shortMB) > 12 { - shortMB = shortMB[:12] - } - return &State{ - Root: abs, - BaseRef: baseRef, - BaseCommit: baseCommit, - HeadCommit: head, - MergeBase: shortMB, - MergeBaseFull: mergeBase, - ChangedFiles: files, - WorkingTreeDirty: dirty, - }, nil -} - -// ResolveWithOptions computes complete v1 repository provenance. -func ResolveWithOptions(root string, options ResolveOptions) (*State, error) { - headRef := options.HeadRef - if headRef == "" { - headRef = "HEAD" - } - if headRef == "HEAD" { - state, err := Resolve(root, options.BaseRef) - if err != nil { - return nil, err - } - if err := enrich(state, options.IncludeUntracked, ""); err != nil { - return nil, err - } - return state, nil - } - abs, err := absPath(root) - if err != nil { - return nil, err - } - head, err := run(abs, "rev-parse", headRef) - if err != nil { - return nil, fmt.Errorf("resolve head %q: %w", headRef, err) - } - baseRef := options.BaseRef - if baseRef == "" { - baseRef = "origin/main" - } - base, err := run(abs, "rev-parse", baseRef) - if err != nil { - return nil, fmt.Errorf("resolve base %q: %w", baseRef, err) - } - mergeBase, err := run(abs, "merge-base", base, head) - if err != nil { - return nil, err - } - state := &State{ - Root: abs, BaseRef: baseRef, BaseCommit: base, HeadCommit: head, - MergeBase: short(mergeBase), MergeBaseFull: mergeBase, - } - if err := enrich(state, options.IncludeUntracked, mergeBase+".."+head); err != nil { - return nil, err - } - return state, nil -} - -func enrich(state *State, includeUntracked bool, comparison string) error { - if comparison == "" { - comparison = state.MergeBaseFull - } - nameStatus, err := run(state.Root, "diff", "--name-status", "--find-renames", comparison) - if err != nil { - return err - } - changes := parseNameStatus(nameStatus) - byPath := map[string]*Change{} - for index := range changes { - byPath[changes[index].Path] = &changes[index] - } - numstat, err := run(state.Root, "diff", "--numstat", "--find-renames", comparison) - if err != nil { - return err - } - for lineIndex, line := range strings.Split(numstat, "\n") { - fields := strings.Split(line, "\t") - if len(fields) != 3 { - continue - } - path := filepath.ToSlash(fields[2]) - change := byPath[path] - if change == nil && lineIndex < len(changes) { - change = &changes[lineIndex] - } - if change == nil { - continue - } - if fields[0] == "-" || fields[1] == "-" { - change.Binary = true - continue - } - change.Additions, _ = strconv.Atoi(fields[0]) - change.Deletions, _ = strconv.Atoi(fields[1]) - } - raw, err := run(state.Root, "diff", "--raw", "--find-renames", comparison) - if err != nil { - return err - } - for _, line := range strings.Split(raw, "\n") { - fields := strings.Fields(line) - if len(fields) < 6 { - continue - } - path := filepath.ToSlash(fields[len(fields)-1]) - if change := byPath[path]; change != nil { - change.OldMode = strings.TrimPrefix(fields[0], ":") - change.NewMode = fields[1] - } - } - patch, err := run(state.Root, "diff", "--binary", "--no-ext-diff", comparison) - if err != nil { - return err - } - status, err := run(state.Root, "status", "--porcelain") - if err != nil { - return err - } - var untrackedRecords []string - var untrackedPatches []string - if includeUntracked { - for _, line := range strings.Split(status, "\n") { - if !strings.HasPrefix(line, "?? ") { - continue - } - path := filepath.ToSlash(strings.TrimSpace(line[3:])) - if path == "" { - continue - } - content, _ := os.ReadFile(filepath.Join(state.Root, filepath.FromSlash(path))) - untrackedRecords = append(untrackedRecords, path+"\x00"+hash(content)) - info, _ := os.Lstat(filepath.Join(state.Root, filepath.FromSlash(path))) - change := Change{Path: path, Status: "untracked", NewMode: "100644"} - if info != nil && info.Mode()&0o111 != 0 { - change.NewMode = "100755" - } - if bytes.IndexByte(content, 0) >= 0 { - change.Binary = true - } else { - change.Additions = bytes.Count(content, []byte{'\n'}) - if len(content) > 0 && content[len(content)-1] != '\n' { - change.Additions++ - } - } - if untrackedPatch, patchErr := diffUntracked(state.Root, path); patchErr == nil { - untrackedPatches = append(untrackedPatches, untrackedPatch) - } - changes = append(changes, change) - byPath[path] = &changes[len(changes)-1] - } - } - sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path }) - state.Changes = changes - state.ChangedFiles = state.ChangedFiles[:0] - for _, change := range changes { - state.ChangedFiles = append(state.ChangedFiles, change.Path) - } - sort.Strings(untrackedRecords) - if len(untrackedPatches) > 0 { - patch += "\n" + strings.Join(untrackedPatches, "\n") - } - state.DiffPatch = patch - state.DiffHash = hash([]byte(patch + "\x00" + strings.Join(untrackedRecords, "\x00"))) - state.WorkingTreeDirty = status != "" - state.WorkingTreeFingerprint = hash([]byte(status + "\x00" + patch + "\x00" + strings.Join(untrackedRecords, "\x00"))) - return nil -} - -func diffUntracked(root, path string) (string, error) { - command := execCommand("git", "diff", "--binary", "--no-index", "--", "/dev/null", filepath.FromSlash(path)) - command.Dir = root - var stdout, stderr bytes.Buffer - command.Stdout = &stdout - command.Stderr = &stderr - err := command.Run() - if err != nil { - var exitError *exec.ExitError - if !errors.As(err, &exitError) || exitError.ExitCode() != 1 { - return "", err - } - } - return strings.TrimSpace(stdout.String()), nil -} - -func parseNameStatus(raw string) []Change { - var changes []Change - for _, line := range strings.Split(raw, "\n") { - fields := strings.Split(line, "\t") - if len(fields) < 2 { - continue - } - status := fields[0] - change := Change{Path: filepath.ToSlash(fields[len(fields)-1]), Status: statusName(status)} - if strings.HasPrefix(status, "R") && len(fields) == 3 { - change.OldPath = filepath.ToSlash(fields[1]) - } - changes = append(changes, change) - } - return changes -} - -func statusName(status string) string { - switch status[0] { - case 'A': - return "added" - case 'D': - return "deleted" - case 'R': - return "renamed" - case 'C': - return "copied" - case 'T': - return "type_changed" - default: - return "modified" - } -} - -func short(commit string) string { - if len(commit) > 12 { - return commit[:12] - } - return commit -} - -func hash(raw []byte) string { - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:]) -} - -func contains(ss []string, s string) bool { - for _, x := range ss { - if x == s { - return true - } - } - return false -} - -// IsRepo reports whether root is a git work tree. -func IsRepo(root string) bool { - _, err := run(root, "rev-parse", "--is-inside-work-tree") - return err == nil -} diff --git a/internal/git/git_coverage_test.go b/internal/git/git_coverage_test.go deleted file mode 100644 index 156ba23..0000000 --- a/internal/git/git_coverage_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package git_test - -import ( - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/git" -) - -func TestResolveDirtyRenameAndErrors(t *testing.T) { - dir := t.TempDir() - run := func(args ...string) { - t.Helper() - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } - run("git", "init") - run("git", "config", "user.email", "t@e.com") - run("git", "config", "user.name", "t") - if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644); err != nil { - t.Fatal(err) - } - run("git", "add", ".") - run("git", "commit", "-m", "c1") - run("git", "branch", "base") - - // modify tracked + untracked + rename-like status - if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("b"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("n"), 0o644); err != nil { - t.Fatal(err) - } - run("git", "add", "new.txt") - run("git", "mv", "new.txt", "renamed.txt") - - st, err := git.Resolve(dir, "base") - if err != nil { - t.Fatal(err) - } - if !st.WorkingTreeDirty || len(st.ChangedFiles) == 0 { - t.Fatalf("%+v", st) - } - // empty baseRef uses origin/main which should fail - if _, err := git.Resolve(dir, ""); err == nil { - t.Fatal("expected missing origin/main") - } - if _, err := git.Resolve(dir, "does-not-exist"); err == nil { - t.Fatal("expected missing ref") - } -} - -func TestResolveNotRepo(t *testing.T) { - if _, err := git.Resolve(t.TempDir(), "HEAD"); err == nil { - t.Fatal("expected error") - } -} diff --git a/internal/git/git_internal_test.go b/internal/git/git_internal_test.go deleted file mode 100644 index 0dc895e..0000000 --- a/internal/git/git_internal_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package git - -import ( - "errors" - "os/exec" - "testing" -) - -func TestContainsAndRunGitStderrEmpty(t *testing.T) { - if contains([]string{"a", "b"}, "b") != true { - t.Fatal("contains") - } - if contains([]string{"a"}, "z") { - t.Fatal("missing") - } - old := run - defer func() { run = old }() - run = func(root string, args ...string) (string, error) { - return "", errors.New("boom") - } - if IsRepo(t.TempDir()) { - t.Fatal("expected false") - } - oldExec := execCommand - defer func() { execCommand = oldExec }() - execCommand = func(name string, arg ...string) *exec.Cmd { - return exec.Command("false") // fails with empty stderr - } - if _, err := runGit(t.TempDir(), "rev-parse", "--is-inside-work-tree"); err == nil { - t.Fatal("expected error") - } -} - -func TestResolveInjectedErrors(t *testing.T) { - oldAbs, oldRun := absPath, run - defer func() { absPath, run = oldAbs, oldRun }() - - absPath = func(string) (string, error) { return "", errors.New("abs") } - if _, err := Resolve(".", "HEAD"); err == nil { - t.Fatal("abs") - } - absPath = func(p string) (string, error) { return "/tmp/repo", nil } - - seq := []struct { - args string - err error - out string - }{ - {"rev-parse --is-inside-work-tree", nil, "true"}, - {"rev-parse HEAD", errors.New("no head"), ""}, - } - i := 0 - run = func(root string, args ...string) (string, error) { - key := joinArgs(args) - if i < len(seq) && key == seq[i].args { - e := seq[i] - i++ - return e.out, e.err - } - return "", errors.New("unexpected " + key) - } - if _, err := Resolve(".", "HEAD"); err == nil { - t.Fatal("head") - } - - // baseCommit fail after verify ok - i = 0 - seq = []struct { - args string - err error - out string - }{ - {"rev-parse --is-inside-work-tree", nil, "true"}, - {"rev-parse HEAD", nil, "h"}, - {"rev-parse --verify origin/main", nil, "origin/main"}, - {"rev-parse origin/main", errors.New("base"), ""}, - } - run = func(root string, args ...string) (string, error) { - key := joinArgs(args) - for _, s := range seq { - if s.args == key { - return s.out, s.err - } - } - return "", errors.New("unexpected " + key) - } - if _, err := Resolve(".", ""); err == nil { - t.Fatal("baseCommit") - } - - // merge-base, diff, status errors + rename + empty path - run = func(root string, args ...string) (string, error) { - switch joinArgs(args) { - case "rev-parse --is-inside-work-tree": - return "true", nil - case "rev-parse HEAD": - return "hhhhhhhhhhhhhhhh", nil - case "rev-parse --verify base": - return "base", nil - case "rev-parse base": - return "bbbbbbbbbbbbbbbb", nil - case "merge-base bbbbbbbbbbbbbbbb hhhhhhhhhhhhhhhh": - return "", errors.New("mb") - default: - return "", errors.New("unexpected " + joinArgs(args)) - } - } - if _, err := Resolve(".", "base"); err == nil { - t.Fatal("merge-base") - } - - run = func(root string, args ...string) (string, error) { - switch joinArgs(args) { - case "rev-parse --is-inside-work-tree": - return "true", nil - case "rev-parse HEAD": - return "hhhhhhhhhhhhhhhh", nil - case "rev-parse --verify base": - return "base", nil - case "rev-parse base": - return "bbbbbbbbbbbbbbbb", nil - case "merge-base bbbbbbbbbbbbbbbb hhhhhhhhhhhhhhhh": - return "mmmmmmmmmmmmmmmm", nil - case "diff --name-only mmmmmmmmmmmmmmmm": - return "", errors.New("diff") - default: - return "", errors.New("unexpected " + joinArgs(args)) - } - } - if _, err := Resolve(".", "base"); err == nil { - t.Fatal("diff") - } - - run = func(root string, args ...string) (string, error) { - switch joinArgs(args) { - case "rev-parse --is-inside-work-tree": - return "true", nil - case "rev-parse HEAD": - return "hhhhhhhhhhhhhhhh", nil - case "rev-parse --verify base": - return "base", nil - case "rev-parse base": - return "bbbbbbbbbbbbbbbb", nil - case "merge-base bbbbbbbbbbbbbbbb hhhhhhhhhhhhhhhh": - return "mmmmmmmmmmmmmmmm", nil - case "diff --name-only mmmmmmmmmmmmmmmm": - return "a.go\n", nil - case "status --porcelain": - return "", errors.New("status") - default: - return "", errors.New("unexpected " + joinArgs(args)) - } - } - if _, err := Resolve(".", "base"); err == nil { - t.Fatal("status") - } - - run = func(root string, args ...string) (string, error) { - switch joinArgs(args) { - case "rev-parse --is-inside-work-tree": - return "true", nil - case "rev-parse HEAD": - return "hhhhhhhhhhhhhhhh", nil - case "rev-parse --verify base": - return "base", nil - case "rev-parse base": - return "bbbbbbbbbbbbbbbb", nil - case "merge-base bbbbbbbbbbbbbbbb hhhhhhhhhhhhhhhh": - return "mmmmmmmmmmmmmmmm", nil - case "diff --name-only mmmmmmmmmmmmmmmm": - return "a.go\n", nil - case "status --porcelain": - return " M a.go\n?? \nR old.go -> new.go\nXX\n", nil - default: - return "", errors.New("unexpected " + joinArgs(args)) - } - } - st, err := Resolve(".", "base") - if err != nil { - t.Fatal(err) - } - if !contains(st.ChangedFiles, "new.go") || !st.WorkingTreeDirty { - t.Fatalf("%+v", st) - } -} - -func joinArgs(args []string) string { - out := "" - for i, a := range args { - if i > 0 { - out += " " - } - out += a - } - return out -} diff --git a/internal/git/git_test.go b/internal/git/git_test.go deleted file mode 100644 index 44b20c8..0000000 --- a/internal/git/git_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package git_test - -import ( - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/git" -) - -func TestResolveAndIsRepo(t *testing.T) { - dir := t.TempDir() - if git.IsRepo(dir) { - t.Fatal("expected not repo") - } - run := func(args ...string) { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } - run("git", "init") - run("git", "config", "user.email", "t@e.com") - run("git", "config", "user.name", "t") - if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644); err != nil { - t.Fatal(err) - } - run("git", "add", ".") - run("git", "commit", "-m", "c1") - st, err := git.Resolve(dir, "HEAD") - if err != nil { - t.Fatal(err) - } - if st.HeadCommit == "" { - t.Fatal("empty head") - } -} diff --git a/internal/git/git_v1_internal_test.go b/internal/git/git_v1_internal_test.go deleted file mode 100644 index 24595fb..0000000 --- a/internal/git/git_v1_internal_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package git - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestResolveWithOptionsFailures(t *testing.T) { - if _, err := ResolveWithOptions(t.TempDir(), ResolveOptions{}); err == nil { - t.Fatal("non-repository accepted") - } - oldAbs, oldRun := absPath, run - defer func() { absPath, run = oldAbs, oldRun }() - - absPath = func(string) (string, error) { return "", errors.New("absolute") } - if _, err := ResolveWithOptions(".", ResolveOptions{HeadRef: "topic"}); err == nil { - t.Fatal("absolute failure ignored") - } - absPath = func(string) (string, error) { return "/repo", nil } - - fake := func(fail string) func(string, ...string) (string, error) { - return func(_ string, arguments ...string) (string, error) { - key := strings.Join(arguments, " ") - if key == fail { - return "", errors.New(fail) - } - switch key { - case "rev-parse --is-inside-work-tree": - return "true", nil - case "rev-parse HEAD", "rev-parse topic": - return "head", nil - case "rev-parse --verify base": - return "base", nil - case "rev-parse base", "rev-parse origin/main": - return "base", nil - case "merge-base base head": - return "merge", nil - case "diff --name-only merge", "status --porcelain", - "diff --name-status --find-renames merge", - "diff --numstat --find-renames merge", - "diff --raw --find-renames merge", - "diff --binary --no-ext-diff merge", - "diff --name-status --find-renames merge..head", - "diff --numstat --find-renames merge..head", - "diff --raw --find-renames merge..head", - "diff --binary --no-ext-diff merge..head": - return "", nil - default: - return "", errors.New("unexpected " + key) - } - } - } - run = fake("diff --name-status --find-renames merge") - if _, err := ResolveWithOptions(".", ResolveOptions{BaseRef: "base"}); err == nil { - t.Fatal("HEAD enrichment failure ignored") - } - for _, failure := range []string{ - "rev-parse topic", "rev-parse origin/main", "merge-base base head", - "diff --name-status --find-renames merge..head", - } { - run = fake(failure) - if _, err := ResolveWithOptions(".", ResolveOptions{HeadRef: "topic"}); err == nil { - t.Fatalf("%s ignored", failure) - } - } - run = fake("") - state, err := ResolveWithOptions(".", ResolveOptions{HeadRef: "topic"}) - if err != nil || state.BaseRef != "origin/main" || state.MergeBase != "merge" { - t.Fatalf("%+v %v", state, err) - } -} - -func TestEnrichFailuresAndParsingEdges(t *testing.T) { - oldRun := run - defer func() { run = oldRun }() - baseResponses := map[string]string{ - "diff --name-status --find-renames base": "M\ta\n", - "diff --numstat --find-renames base": "1\t2\ta\n", - "diff --raw --find-renames base": ":100644 100755 abc def M\ta\n", - "diff --binary --no-ext-diff base": "patch", - "status --porcelain": "", - } - for _, failure := range []string{ - "diff --name-status --find-renames base", - "diff --numstat --find-renames base", - "diff --raw --find-renames base", - "diff --binary --no-ext-diff base", - "status --porcelain", - } { - run = func(_ string, arguments ...string) (string, error) { - key := strings.Join(arguments, " ") - if key == failure { - return "", errors.New(failure) - } - return baseResponses[key], nil - } - if err := enrich(&State{Root: t.TempDir(), MergeBaseFull: "base"}, false, ""); err == nil { - t.Fatalf("%s ignored", failure) - } - } - - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "plain"), []byte("one"), 0o644); err != nil { - t.Fatal(err) - } - run = func(_ string, arguments ...string) (string, error) { - key := strings.Join(arguments, " ") - switch key { - case "diff --name-status --find-renames base": - return "M\ta\n", nil - case "diff --numstat --find-renames base": - return "1\t2\tunknown\n3\t4\talso-unknown\nbad", nil - case "diff --raw --find-renames base": - return "short\n:100644 100755 abc def M\ta", nil - case "diff --binary --no-ext-diff base": - return "patch", nil - case "status --porcelain": - return "?? \n?? plain", nil - default: - return "", errors.New(key) - } - } - state := &State{Root: root, MergeBaseFull: "base"} - if err := enrich(state, true, ""); err != nil { - t.Fatal(err) - } - if len(state.Changes) != 2 || state.Changes[1].Additions != 1 { - t.Fatalf("%+v", state.Changes) - } - - run = func(_ string, arguments ...string) (string, error) { - key := strings.Join(arguments, " ") - if key == "diff --numstat --find-renames base" { - return "-\t-\ta", nil - } - return baseResponses[key], nil - } - state = &State{Root: root, MergeBaseFull: "base"} - if err := enrich(state, false, "base"); err != nil || !state.Changes[0].Binary { - t.Fatalf("%+v %v", state, err) - } - - changes := parseNameStatus("bad\nC100\told\tcopy\nT\tkind\nA\tnew") - if len(changes) != 3 || changes[0].Status != "copied" || - changes[1].Status != "type_changed" || changes[2].Status != "added" { - t.Fatalf("%+v", changes) - } - if short("tiny") != "tiny" { - t.Fatal("short commit") - } -} - -func TestDiffUntrackedUnexpectedExit(t *testing.T) { - oldExec := execCommand - defer func() { execCommand = oldExec }() - execCommand = func(string, ...string) *exec.Cmd { - return exec.Command("sh", "-c", "exit 2") - } - if _, err := diffUntracked(t.TempDir(), "file"); err == nil { - t.Fatal("unexpected exit accepted") - } -} diff --git a/internal/git/git_v1_test.go b/internal/git/git_v1_test.go deleted file mode 100644 index 9d3a6a2..0000000 --- a/internal/git/git_v1_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package git_test - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "testing" - - repogit "github.com/hypertrial/intentci/internal/git" -) - -func TestResolveCompleteRepositoryState(t *testing.T) { - root := t.TempDir() - run := func(arguments ...string) string { - t.Helper() - command := exec.Command("git", arguments...) - command.Dir = root - output, err := command.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v: %s", arguments, err, output) - } - return string(bytes.TrimSpace(output)) - } - run("init") - run("config", "user.email", "test@example.com") - run("config", "user.name", "Test") - for name, content := range map[string][]byte{ - "rename.txt": []byte("rename\n"), - "delete.txt": []byte("delete\n"), - "modify.txt": []byte("before\n"), - } { - if err := os.WriteFile(filepath.Join(root, name), content, 0o644); err != nil { - t.Fatal(err) - } - } - run("add", ".") - run("commit", "-m", "base") - base := run("rev-parse", "HEAD") - - run("mv", "rename.txt", "renamed.txt") - if err := os.Remove(filepath.Join(root, "delete.txt")); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "modify.txt"), []byte("after\nmore\n"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "binary.bin"), []byte{0, 1, 2}, 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "untracked.txt"), []byte("one\n"), 0o755); err != nil { - t.Fatal(err) - } - - state, err := repogit.ResolveWithOptions(root, repogit.ResolveOptions{ - BaseRef: "HEAD", IncludeUntracked: true, - }) - if err != nil { - t.Fatal(err) - } - if state.BaseCommit != base || state.HeadCommit != base || !state.WorkingTreeDirty || - state.DiffHash == "" || state.WorkingTreeFingerprint == "" || - !bytes.Contains([]byte(state.DiffPatch), []byte("untracked.txt")) { - t.Fatalf("%+v", state) - } - byPath := map[string]repogit.Change{} - for _, change := range state.Changes { - byPath[change.Path] = change - } - if byPath["renamed.txt"].Status != "renamed" || byPath["renamed.txt"].OldPath != "rename.txt" || - byPath["delete.txt"].Status != "deleted" || byPath["modify.txt"].Additions == 0 || - !byPath["binary.bin"].Binary || byPath["untracked.txt"].Status != "untracked" || - byPath["untracked.txt"].NewMode != "100755" { - t.Fatalf("%+v", state.Changes) - } - firstHash := state.DiffHash - if err := os.WriteFile(filepath.Join(root, "untracked.txt"), []byte("two\n"), 0o755); err != nil { - t.Fatal(err) - } - updated, err := repogit.ResolveWithOptions(root, repogit.ResolveOptions{BaseRef: "HEAD", IncludeUntracked: true}) - if err != nil || updated.DiffHash == firstHash { - t.Fatalf("err=%v first=%s updated=%s", err, firstHash, updated.DiffHash) - } - withoutUntracked, err := repogit.ResolveWithOptions(root, repogit.ResolveOptions{ - BaseRef: "HEAD", IncludeUntracked: false, - }) - if err != nil { - t.Fatal(err) - } - for _, change := range withoutUntracked.Changes { - if change.Status == "untracked" { - t.Fatalf("%+v", withoutUntracked.Changes) - } - } - - run("add", ".") - run("commit", "-m", "head") - head := run("rev-parse", "HEAD") - explicit, err := repogit.ResolveWithOptions(root, repogit.ResolveOptions{ - BaseRef: base, HeadRef: head, - }) - if err != nil || explicit.HeadCommit != head || explicit.MergeBaseFull != base { - t.Fatalf("%+v %v", explicit, err) - } -} diff --git a/internal/impact/benchmark_test.go b/internal/impact/benchmark_test.go deleted file mode 100644 index 645a88b..0000000 --- a/internal/impact/benchmark_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package impact_test - -import ( - "fmt" - "testing" - - "github.com/hypertrial/intentci/internal/impact" - "github.com/hypertrial/intentci/internal/ir" -) - -func BenchmarkV1ChangeImpact10000Files(b *testing.B) { - document := &ir.Document{Requirements: make([]ir.Requirement, 100)} - for index := range document.Requirements { - document.Requirements[index] = ir.Requirement{ - ID: fmt.Sprintf("REQ-%03d", index), Status: "active", - AppliesTo: ir.AppliesTo{Paths: []string{fmt.Sprintf("src/%03d/**", index)}}, - } - } - files := make([]string, 10_000) - for index := range files { - files[index] = fmt.Sprintf("src/%03d/file-%05d.go", index%100, index) - } - b.ResetTimer() - for iteration := 0; iteration < b.N; iteration++ { - selection := impact.Select(document, impact.Options{ChangedFiles: files}) - if len(selection.Requirements) != 100 { - b.Fatalf("selected %d requirements", len(selection.Requirements)) - } - } -} diff --git a/internal/impact/fuzz_test.go b/internal/impact/fuzz_test.go deleted file mode 100644 index 15135c9..0000000 --- a/internal/impact/fuzz_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package impact - -import ( - "path/filepath" - "testing" - - "github.com/bmatcuk/doublestar/v4" -) - -func FuzzV1PathMatching(f *testing.F) { - f.Add("src/**/*.go", "src/pkg/file.go") - f.Add("[", "src/file.go") - f.Add("docs/**", `docs\v1.md`) - f.Fuzz(func(t *testing.T, pattern, file string) { - if len(pattern) > 512 || len(file) > 512 { - t.Skip() - } - pattern = filepath.ToSlash(pattern) - file = filepath.ToSlash(file) - matched, err := doublestar.Match(pattern, file) - want := err == nil && matched - if got := PathMatches([]string{pattern}, file); got != want { - t.Fatalf("PathMatches(%q, %q)=%t, want %t", pattern, file, got, want) - } - }) -} diff --git a/internal/impact/impact.go b/internal/impact/impact.go deleted file mode 100644 index 96d037a..0000000 --- a/internal/impact/impact.go +++ /dev/null @@ -1,258 +0,0 @@ -package impact - -import ( - "path/filepath" - - "github.com/bmatcuk/doublestar/v4" - - "github.com/hypertrial/intentci/internal/ir" -) - -// Selection is the set of requirements/obligations to verify. -type Selection struct { - Requirements []ir.Requirement - Unmapped []string -} - -// Options configures impact analysis. -type Options struct { - All bool - RequirementID string - ObligationID string - ChangedFiles []string - GlobalPaths []string - RunUnmappedRequirements bool -} - -// Select chooses active requirements affected by changed files. -func Select(doc *ir.Document, opt Options) Selection { - active := doc.ActiveRequirements() - if opt.RequirementID != "" { - byID := make(map[string]ir.Requirement, len(active)) - for _, requirement := range active { - byID[requirement.ID] = requirement - } - selected := map[string]bool{opt.RequirementID: true} - for changed := true; changed; { - changed = false - for id := range selected { - for _, dependency := range byID[id].DependsOn { - if !selected[dependency] { - selected[dependency] = true - changed = true - } - } - } - } - var filtered []ir.Requirement - for _, requirement := range active { - if !selected[requirement.ID] { - continue - } - if opt.ObligationID != "" && requirement.ID == opt.RequirementID { - requirement = filterObligation(requirement, opt.ObligationID) - } - filtered = append(filtered, requirement) - } - return Selection{Requirements: filtered} - } - if opt.All { - out := active - if opt.ObligationID != "" { - tmp := make([]ir.Requirement, 0, len(out)) - for _, r := range out { - tmp = append(tmp, filterObligation(r, opt.ObligationID)) - } - out = tmp - } - return Selection{Requirements: out} - } - // Changed-mode with no diff: nothing affected (do not silently verify all). - if len(opt.ChangedFiles) == 0 { - return Selection{Requirements: nil, Unmapped: nil} - } - - // dependency closure of path-matched requirements - matched := map[string]bool{} - globalInvalidation := false - for _, file := range opt.ChangedFiles { - if pathMatches(opt.GlobalPaths, file) { - globalInvalidation = true - break - } - } - for _, r := range active { - if globalInvalidation || matchesPaths(r, opt.ChangedFiles) || contains(opt.ChangedFiles, r.SourcePath) || verifierInputsMatch(r, opt.ChangedFiles) { - matched[r.ID] = true - } - } - // propagate depends_on reverse: if A depends on B and B matched, A is affected; - // also if A matched, dependencies of A should run. - byID := map[string]ir.Requirement{} - for _, r := range active { - byID[r.ID] = r - } - changed := true - for changed { - changed = false - for _, r := range active { - if matched[r.ID] { - for _, dep := range r.DependsOn { - if !matched[dep] { - if _, ok := byID[dep]; ok { - matched[dep] = true - changed = true - } - } - } - continue - } - for _, dep := range r.DependsOn { - if matched[dep] { - matched[r.ID] = true - changed = true - break - } - } - } - } - - var selected []ir.Requirement - for _, r := range active { - if matched[r.ID] { - if opt.ObligationID != "" { - r = filterObligation(r, opt.ObligationID) - } - selected = append(selected, r) - } - } - - var unmapped []string - for _, f := range opt.ChangedFiles { - hit := false - for _, r := range active { - if pathMatches(r.AppliesTo.Paths, f) || pathMatches(r.Boundaries.Allowed, f) || - pathMatches(providerInputs(r), f) || r.SourcePath == f || pathMatches(opt.GlobalPaths, f) { - hit = true - break - } - } - if !hit { - unmapped = append(unmapped, f) - } - } - if opt.RunUnmappedRequirements && len(unmapped) > 0 { - for _, requirement := range active { - if len(requirement.AppliesTo.Paths) == 0 { - matched[requirement.ID] = true - } - } - selected = selected[:0] - for _, requirement := range active { - if matched[requirement.ID] { - selected = append(selected, requirement) - } - } - } - return Selection{Requirements: selected, Unmapped: unmapped} -} - -func filterObligation(r ir.Requirement, id string) ir.Requirement { - byID := make(map[string]ir.Obligation, len(r.Obligations)) - for _, obligation := range r.Obligations { - byID[obligation.ID] = obligation - } - selected := map[string]bool{id: true} - for changed := true; changed; { - changed = false - for obligationID := range selected { - for _, dependency := range byID[obligationID].DependsOn { - if !selected[dependency] { - selected[dependency] = true - changed = true - } - } - } - } - var obs []ir.Obligation - for _, o := range r.Obligations { - if selected[o.ID] { - obs = append(obs, o) - } - } - r.Obligations = obs - return r -} - -func matchesPaths(r ir.Requirement, files []string) bool { - paths := r.AppliesTo.Paths - if len(paths) == 0 { - // no applies_to → affected by any change (conservative) - return len(files) > 0 - } - for _, f := range files { - if pathMatches(paths, f) { - return true - } - } - return false -} - -func verifierInputsMatch(requirement ir.Requirement, files []string) bool { - inputs := providerInputs(requirement) - for _, file := range files { - if pathMatches(inputs, file) { - return true - } - } - return false -} - -func providerInputs(requirement ir.Requirement) []string { - var inputs []string - var walk func(ir.VerifyNode) - walk = func(node ir.VerifyNode) { - if node.Provider != nil { - inputs = append(inputs, node.Provider.Inputs...) - } - for _, child := range node.All { - walk(child) - } - for _, child := range node.Any { - walk(child) - } - if node.Not != nil { - walk(*node.Not) - } - } - for _, obligation := range requirement.Obligations { - walk(obligation.Verify) - } - return inputs -} - -func contains(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} - -func pathMatches(patterns []string, file string) bool { - file = filepath.ToSlash(file) - for _, p := range patterns { - p = filepath.ToSlash(p) - ok, err := doublestar.Match(p, file) - if err == nil && ok { - return true - } - } - return false -} - -// PathMatches reports whether file matches any pattern. -func PathMatches(patterns []string, file string) bool { - return pathMatches(patterns, file) -} diff --git a/internal/impact/impact_coverage_test.go b/internal/impact/impact_coverage_test.go deleted file mode 100644 index b3f2ad3..0000000 --- a/internal/impact/impact_coverage_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package impact_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/impact" - "github.com/hypertrial/intentci/internal/ir" -) - -func TestSelectRequirementObligationDepsUnmapped(t *testing.T) { - doc := &ir.Document{Requirements: []ir.Requirement{ - {ID: "REQ-A", Status: "active", AppliesTo: ir.AppliesTo{Paths: []string{"src/**"}}, - Obligations: []ir.Obligation{{ID: "O1"}, {ID: "O2"}}, Boundaries: ir.Boundaries{Allowed: []string{"src/**"}}}, - {ID: "REQ-B", Status: "active", DependsOn: []string{"REQ-A"}, AppliesTo: ir.AppliesTo{Paths: []string{"pkg/**"}}, - Obligations: []ir.Obligation{{ID: "O1"}}}, - {ID: "REQ-C", Status: "active", AppliesTo: ir.AppliesTo{}, // any change - Obligations: []ir.Obligation{{ID: "O1"}}}, - {ID: "REQ-D", Status: "active", DependsOn: []string{"REQ-B"}, AppliesTo: ir.AppliesTo{Paths: []string{"other/**"}}, - Obligations: []ir.Obligation{{ID: "O1"}}}, - }} - - sel := impact.Select(doc, impact.Options{RequirementID: "REQ-A", ObligationID: "O2"}) - if len(sel.Requirements) != 1 || len(sel.Requirements[0].Obligations) != 1 || sel.Requirements[0].Obligations[0].ID != "O2" { - t.Fatalf("%+v", sel) - } - - sel = impact.Select(doc, impact.Options{All: true, ObligationID: "O1"}) - if len(sel.Requirements) == 0 { - t.Fatal("expected all") - } - for _, r := range sel.Requirements { - if len(r.Obligations) != 1 || r.Obligations[0].ID != "O1" { - t.Fatalf("%+v", r) - } - } - - sel = impact.Select(doc, impact.Options{ChangedFiles: []string{"src/a.go", "unmapped.txt"}}) - if len(sel.Unmapped) == 0 { - t.Fatalf("expected unmapped %+v", sel) - } - ids := map[string]bool{} - for _, r := range sel.Requirements { - ids[r.ID] = true - } - if !ids["REQ-A"] || !ids["REQ-B"] || !ids["REQ-C"] || !ids["REQ-D"] { - t.Fatalf("deps closure %+v", ids) - } - - sel = impact.Select(doc, impact.Options{ChangedFiles: []string{"docs/x.md"}, ObligationID: "O1"}) - // REQ-C has empty applies_to so matches any change - if len(sel.Requirements) == 0 { - t.Fatal("expected REQ-C") - } - - sel = impact.Select(doc, impact.Options{ChangedFiles: []string{"pkg/x.go"}}) - if len(sel.Requirements) == 0 { - t.Fatal("expected match") - } -} diff --git a/internal/impact/impact_test.go b/internal/impact/impact_test.go deleted file mode 100644 index 7ce3769..0000000 --- a/internal/impact/impact_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package impact_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/impact" - "github.com/hypertrial/intentci/internal/ir" -) - -func TestSelectChanged(t *testing.T) { - doc := &ir.Document{Requirements: []ir.Requirement{ - {ID: "REQ-1", Status: "active", AppliesTo: ir.AppliesTo{Paths: []string{"src/**"}}, Obligations: []ir.Obligation{{ID: "O"}}}, - {ID: "REQ-2", Status: "active", AppliesTo: ir.AppliesTo{Paths: []string{"docs/**"}}, Obligations: []ir.Obligation{{ID: "O"}}}, - {ID: "REQ-3", Status: "draft", AppliesTo: ir.AppliesTo{Paths: []string{"**"}}, Obligations: []ir.Obligation{{ID: "O"}}}, - }} - sel := impact.Select(doc, impact.Options{ChangedFiles: []string{"src/a.go"}}) - if len(sel.Requirements) != 1 || sel.Requirements[0].ID != "REQ-1" { - t.Fatalf("%+v", sel) - } - if !impact.PathMatches([]string{"src/**"}, "src/a.go") { - t.Fatal("path match") - } - all := impact.Select(doc, impact.Options{All: true}) - if len(all.Requirements) != 2 { - t.Fatalf("%d", len(all.Requirements)) - } - empty := impact.Select(doc, impact.Options{ChangedFiles: nil}) - if len(empty.Requirements) != 0 { - t.Fatalf("changed-mode with empty diff must select nothing, got %+v", empty) - } -} diff --git a/internal/impact/impact_v1_test.go b/internal/impact/impact_v1_test.go deleted file mode 100644 index b6f75a0..0000000 --- a/internal/impact/impact_v1_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package impact_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/impact" - "github.com/hypertrial/intentci/internal/ir" -) - -func TestV1ExplicitDependencyClosures(t *testing.T) { - document := &ir.Document{Requirements: []ir.Requirement{ - { - ID: "A", Status: "active", DependsOn: []string{"B"}, - Obligations: []ir.Obligation{ - {ID: "one", DependsOn: []string{"two"}}, - {ID: "two", DependsOn: []string{"three"}}, - {ID: "three"}, - }, - }, - {ID: "B", Status: "active", DependsOn: []string{"C"}, Obligations: []ir.Obligation{{ID: "one"}}}, - {ID: "C", Status: "active", Obligations: []ir.Obligation{{ID: "one"}}}, - }} - selected := impact.Select(document, impact.Options{RequirementID: "A", ObligationID: "one"}) - if len(selected.Requirements) != 3 || len(selected.Requirements[0].Obligations) != 3 { - t.Fatalf("%+v", selected) - } -} - -func TestV1GlobalInputsAndUnmappedSelection(t *testing.T) { - nested := ir.VerifyNode{ - All: []ir.VerifyNode{{Provider: &ir.ProviderSpec{Provider: "command", Inputs: []string{"all/**"}}}}, - Any: []ir.VerifyNode{{Provider: &ir.ProviderSpec{Provider: "command", Inputs: []string{"any/**"}}}}, - Not: &ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", Inputs: []string{"not/**"}}}, - } - document := &ir.Document{Requirements: []ir.Requirement{ - { - ID: "mapped", Status: "active", SourcePath: "requirements/mapped.md", - AppliesTo: ir.AppliesTo{Paths: []string{"src/**"}}, - Obligations: []ir.Obligation{{ID: "O", Verify: nested}}, - }, - {ID: "global", Status: "active", Obligations: []ir.Obligation{{ID: "O"}}}, - }} - for _, changed := range []string{"all/x", "any/x", "not/x", "requirements/mapped.md"} { - selection := impact.Select(document, impact.Options{ChangedFiles: []string{changed}}) - if len(selection.Requirements) == 0 { - t.Fatalf("%s was not mapped: %+v", changed, selection) - } - } - global := impact.Select(document, impact.Options{ - ChangedFiles: []string{"config/global.yaml"}, GlobalPaths: []string{"config/**"}, - }) - if len(global.Requirements) != 2 || len(global.Unmapped) != 0 { - t.Fatalf("%+v", global) - } - unmapped := impact.Select(document, impact.Options{ - ChangedFiles: []string{"other/file"}, RunUnmappedRequirements: true, - }) - if len(unmapped.Unmapped) != 1 || len(unmapped.Requirements) != 1 || - unmapped.Requirements[0].ID != "global" { - t.Fatalf("%+v", unmapped) - } - if impact.PathMatches([]string{"[", "src/**"}, "src/file") != true || - impact.PathMatches([]string{"["}, "src/file") { - t.Fatal("path matching") - } -} diff --git a/internal/initcmd/init.go b/internal/initcmd/init.go deleted file mode 100644 index 49c601e..0000000 --- a/internal/initcmd/init.go +++ /dev/null @@ -1,238 +0,0 @@ -package initcmd - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/hypertrial/intentci/internal/config" -) - -var writeFile = os.WriteFile -var mkdirAll = os.MkdirAll - -// Options configures initialization. -type Options struct { - Root string - Force bool - Language string - CIGithub bool - NoExample bool -} - -// Run initializes .intentci in a repository. -func Run(opt Options) error { - dir := config.Dir(opt.Root) - cfgPath := config.Path(opt.Root) - if _, err := os.Stat(cfgPath); err == nil && !opt.Force { - return fmt.Errorf("%s already exists (use --force)", cfgPath) - } - if err := mkdirAll(filepath.Join(dir, "requirements"), 0o755); err != nil { - return err - } - name := filepath.Base(opt.Root) - if name == "" || name == "." { - name = "project" - } - cfg := fmt.Sprintf(`version: 1 - -project: - name: %s - -requirements: - paths: - - .intentci/requirements/**/*.md - -verification: - default_timeout: 10m - max_parallel: 4 - fail_fast: false - working_directory: . - require_clean_worktree: false - -change_impact: - base_ref: origin/main - include_untracked: true - run_unmapped_requirements: false - fail_on_unmapped: false - global_paths: - - .intentci/config.yaml - - .intentci/providers/** - - .intentci/schemas/** - - go.mod - - go.sum - - package.json - - package-lock.json - - pyproject.toml - - uv.lock - - Cargo.toml - - Cargo.lock - - pom.xml - -evidence: - directory: .intentci/runs - retain_stdout: true - retain_stderr: true - hash_algorithm: sha256 - redact: - environment: - - "*TOKEN*" - - "*SECRET*" - - "*PASSWORD*" - - "*KEY*" - -repair: - max_attempts: 3 - stop_on_repeated_diff: true - stop_on_repeated_failure: true - allow_requirement_changes: false - allow_test_changes: true - protected_paths: [] - -ci: - fail_on: - - fail - - error - - unproven - - uncertain - - review_required - -telemetry: - enabled: false -`, name) - if err := writeFile(cfgPath, []byte(cfg), 0o644); err != nil { - return err - } - - gitignore := filepath.Join(dir, ".gitignore") - _ = writeFile(gitignore, []byte("runs/\ncache/\ntmp/\nconfig.local.yaml\n"), 0o644) - - if !opt.NoExample { - req := exampleRequirement(opt.Language) - if err := writeFile(filepath.Join(dir, "requirements", "REQ-001.md"), []byte(req), 0o644); err != nil { - return err - } - } - - if opt.CIGithub { - wfDir := filepath.Join(opt.Root, ".github", "workflows") - if err := mkdirAll(wfDir, 0o755); err != nil { - return err - } - wf := `name: intentci -on: [push, pull_request] -jobs: - verify: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - - name: Install IntentCI - run: go install github.com/hypertrial/intentci/cmd/intentci@latest - - name: Verify - run: intentci verify --changed --format json -` - if err := writeFile(filepath.Join(wfDir, "intentci.yml"), []byte(wf), 0o644); err != nil { - return err - } - } - return nil -} - -func exampleRequirement(language string) string { - cmd := `"printf 'intentci-ok\n'"` - inherited := "" - switch language { - case "go": - cmd = `"(go test ./...) && printf 'intentci-ok\n'"` - inherited = ` - inherit_environment: - - HOME - - GOCACHE` - case "python": - cmd = `"(pytest -q) && printf 'intentci-ok\n'"` - inherited = ` - inherit_environment: - - HOME - - PYTHONPATH - - VIRTUAL_ENV` - case "typescript", "ts": - cmd = `"(npm test) && printf 'intentci-ok\n'"` - inherited = ` - inherit_environment: - - HOME - - NODE_OPTIONS` - case "rust": - cmd = `"(cargo test) && printf 'intentci-ok\n'"` - inherited = ` - inherit_environment: - - HOME - - CARGO_HOME - - RUSTUP_HOME` - case "java": - cmd = `"(mvn test) && printf 'intentci-ok\n'"` - inherited = ` - inherit_environment: - - HOME - - JAVA_HOME - - MAVEN_OPTS` - } - return fmt.Sprintf(`--- -id: REQ-001 -title: Example requirement -status: active -priority: required -owners: - - repository-maintainers -depends_on: [] -applies_to: - paths: - - "**" -tags: - - example ---- - -# Intent - -The repository smoke checks should pass. - -# Rationale - -Provides a starting obligation mapped to an existing test command. - -# Constraints - -## Must - -- id: CON-001 - statement: Prefer existing repository test tooling. - -## Must Not - -- id: CON-002 - statement: Do not invent a parallel test framework. - -# Obligations - -`+"```yaml"+` -- id: OBL-001 - statement: Smoke checks pass. - required: true - verify: - all: - - provider: command - id: smoke - run: %s%s - result: - type: exit_code - equals: 0 - stdout: - contains: intentci-ok -`+"```"+` -`, cmd, inherited) -} diff --git a/internal/initcmd/init_coverage_test.go b/internal/initcmd/init_coverage_test.go deleted file mode 100644 index 23e5c31..0000000 --- a/internal/initcmd/init_coverage_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package initcmd_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/hypertrial/intentci/internal/initcmd" -) - -func TestExampleLanguagesAndErrors(t *testing.T) { - for _, lang := range []string{"", "go", "python", "typescript", "ts", "rust", "java", "other"} { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root, Language: lang}); err != nil { - t.Fatal(err) - } - body, err := os.ReadFile(filepath.Join(root, ".intentci", "requirements", "REQ-001.md")) - if err != nil { - t.Fatal(err) - } - s := string(body) - switch lang { - case "go": - if !strings.Contains(s, "go test") { - t.Fatal(s) - } - case "python": - if !strings.Contains(s, "pytest") { - t.Fatal(s) - } - case "typescript", "ts": - if !strings.Contains(s, "npm test") { - t.Fatal(s) - } - case "rust": - if !strings.Contains(s, "cargo test") { - t.Fatal(s) - } - case "java": - if !strings.Contains(s, "mvn test") { - t.Fatal(s) - } - default: - if !strings.Contains(s, "intentci-ok") { - t.Fatal(s) - } - } - } - - // mkdir fail for requirements: parent path is a file - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, ".intentci"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := initcmd.Run(initcmd.Options{Root: root}); err == nil { - t.Fatal("expected error") - } - - // CI github mkdir fail - root = t.TempDir() - if err := os.WriteFile(filepath.Join(root, ".github"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := initcmd.Run(initcmd.Options{Root: root, CIGithub: true, NoExample: true}); err == nil { - t.Fatal("expected ci mkdir error") - } -} diff --git a/internal/initcmd/init_internal_test.go b/internal/initcmd/init_internal_test.go deleted file mode 100644 index 87627bb..0000000 --- a/internal/initcmd/init_internal_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package initcmd - -import ( - "errors" - "os" - "path/filepath" - "testing" -) - -func TestRootNameAndWriteErrors(t *testing.T) { - oldW, oldM := writeFile, mkdirAll - defer func() { writeFile, mkdirAll = oldW, oldM }() - - root := t.TempDir() + string(filepath.Separator) + "." - if filepath.Base(root) != "." { - t.Fatalf("base=%q root=%q", filepath.Base(root), root) - } - if err := Run(Options{Root: root}); err != nil { - t.Fatal(err) - } - - writeFile = func(string, []byte, os.FileMode) error { return errors.New("cfg") } - if err := Run(Options{Root: t.TempDir(), Force: true}); err == nil { - t.Fatal("cfg write") - } - - n := 0 - writeFile = func(name string, data []byte, perm os.FileMode) error { - n++ - if n >= 3 { - return errors.New("example") - } - return oldW(name, data, perm) - } - if err := Run(Options{Root: t.TempDir()}); err == nil { - t.Fatal("example write") - } - - n = 0 - writeFile = func(name string, data []byte, perm os.FileMode) error { - n++ - if n >= 3 { - return errors.New("wf") - } - return oldW(name, data, perm) - } - if err := Run(Options{Root: t.TempDir(), CIGithub: true, NoExample: true}); err == nil { - t.Fatal("workflow write") - } -} diff --git a/internal/initcmd/init_test.go b/internal/initcmd/init_test.go deleted file mode 100644 index 3b9a668..0000000 --- a/internal/initcmd/init_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package initcmd_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/initcmd" -) - -func TestInit(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root, Language: "go", CIGithub: true}); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(root, ".intentci", "config.yaml")); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(root, ".intentci", "requirements", "REQ-001.md")); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(root, ".github", "workflows", "intentci.yml")); err != nil { - t.Fatal(err) - } - if err := initcmd.Run(initcmd.Options{Root: root}); err == nil { - t.Fatal("expected exists error") - } - if err := initcmd.Run(initcmd.Options{Root: root, Force: true, NoExample: true}); err != nil { - t.Fatal(err) - } -} diff --git a/internal/ir/fuzz_test.go b/internal/ir/fuzz_test.go deleted file mode 100644 index f3f57fe..0000000 --- a/internal/ir/fuzz_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package ir - -import ( - "reflect" - "testing" -) - -func FuzzV1LogicalExpressionNormalization(f *testing.F) { - f.Add([]byte{0, 1, 2, 3}) - f.Add([]byte{3, 3, 0}) - f.Fuzz(func(t *testing.T, shape []byte) { - if len(shape) > 64 { - t.Skip() - } - nodes := make([]VerifyNode, 0, len(shape)) - for _, value := range shape { - provider := &ProviderSpec{Provider: "command"} - if value%3 == 0 { - provider.ID = "explicit" - } - nodes = append(nodes, VerifyNode{Provider: provider}) - } - if len(nodes) == 0 { - nodes = append(nodes, VerifyNode{Provider: &ProviderSpec{Provider: "command"}}) - } - document := &Document{SchemaVersion: SchemaVersion, Hash: "document"} - requirements := []Requirement{{ - ID: "R", Hash: "requirement", - Obligations: []Obligation{{ - ID: "O", Hash: "obligation", Verify: VerifyNode{All: nodes}, - }}, - }} - first, err := BuildVerificationPlan(document, requirements) - if err != nil { - t.Fatal(err) - } - second, err := BuildVerificationPlan(document, requirements) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(first, second) { - t.Fatalf("logical normalization is nondeterministic:\n%+v\n%+v", first, second) - } - }) -} diff --git a/internal/ir/ir.go b/internal/ir/ir.go deleted file mode 100644 index 0666ebe..0000000 --- a/internal/ir/ir.go +++ /dev/null @@ -1,269 +0,0 @@ -package ir - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "sort" -) - -// SchemaVersion is the Intent IR schema version. -const SchemaVersion = 1 - -// Document is the canonical compiled Intent IR. -type Document struct { - SchemaVersion int `json:"schema_version"` - Project string `json:"project"` - Hash string `json:"hash"` - Requirements []Requirement `json:"requirements"` -} - -// VerificationPlan is the immutable executable subset selected for a run. -type VerificationPlan struct { - SchemaVersion int `json:"schema_version"` - IRHash string `json:"ir_hash"` - Hash string `json:"hash"` - Requirements []PlanRequirement `json:"requirements"` -} - -// PlanRequirement records the obligations selected for a requirement. -type PlanRequirement struct { - ID string `json:"id"` - Hash string `json:"hash"` - Obligations []PlanObligation `json:"obligations"` -} - -// PlanObligation records an obligation and its verification expression. -type PlanObligation struct { - ID string `json:"id"` - Hash string `json:"hash"` - Verify VerifyNode `json:"verify"` -} - -// Requirement is a compiled requirement. -type Requirement struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Priority string `json:"priority"` - Owners []string `json:"owners,omitempty"` - DependsOn []string `json:"depends_on,omitempty"` - AppliesTo AppliesTo `json:"applies_to"` - Tags []string `json:"tags,omitempty"` - Timeout string `json:"timeout,omitempty"` - Intent string `json:"intent"` - Rationale string `json:"rationale,omitempty"` - Constraints []Constraint `json:"constraints,omitempty"` - Boundaries Boundaries `json:"boundaries"` - Obligations []Obligation `json:"obligations"` - SourcePath string `json:"source_path"` - Hash string `json:"hash"` -} - -type AppliesTo struct { - Paths []string `json:"paths,omitempty"` - Symbols []string `json:"symbols,omitempty"` -} - -type Constraint struct { - ID string `json:"id"` - Kind string `json:"kind"` // must | must_not - Statement string `json:"statement"` -} - -type Boundaries struct { - Allowed []string `json:"allowed,omitempty"` - Forbidden []string `json:"forbidden,omitempty"` -} - -type Obligation struct { - ID string `json:"id"` - Statement string `json:"statement"` - Required bool `json:"required"` - Description string `json:"description,omitempty"` - Rationale string `json:"rationale,omitempty"` - EvidenceClass string `json:"evidence_class,omitempty"` - ConfidenceThreshold *float64 `json:"confidence_threshold,omitempty"` - Timeout string `json:"timeout,omitempty"` - Retry Retry `json:"retry,omitempty"` - Platforms []string `json:"platforms,omitempty"` - Tags []string `json:"tags,omitempty"` - DependsOn []string `json:"depends_on,omitempty"` - ManualReview bool `json:"manual_review,omitempty"` - Severity string `json:"severity,omitempty"` - Verify VerifyNode `json:"verify"` - Hash string `json:"hash"` -} - -// Retry configures repeated provider execution. -type Retry struct { - Attempts int `json:"attempts,omitempty" yaml:"attempts,omitempty"` - Backoff string `json:"backoff,omitempty" yaml:"backoff,omitempty"` -} - -// VerifyNode is a logical verification expression. -type VerifyNode struct { - All []VerifyNode `json:"all,omitempty"` - Any []VerifyNode `json:"any,omitempty"` - Not *VerifyNode `json:"not,omitempty"` - Provider *ProviderSpec `json:"provider,omitempty"` -} - -// ProviderSpec configures a single provider invocation. -type ProviderSpec struct { - Provider string `json:"provider"` - ID string `json:"id,omitempty"` - Run string `json:"run,omitempty"` - Report string `json:"report,omitempty"` - Result map[string]any `json:"result,omitempty"` - Allowed []string `json:"allowed,omitempty"` - Forbidden []string `json:"forbidden,omitempty"` - Paths []string `json:"paths,omitempty"` - Expect map[string]any `json:"expect,omitempty"` - Assert map[string]any `json:"assert,omitempty"` - Match map[string]any `json:"match,omitempty"` - Allow map[string]any `json:"allow,omitempty"` - Prompt string `json:"prompt,omitempty"` - WorkingDirectory string `json:"working_directory,omitempty"` - InheritEnv []string `json:"inherit_environment,omitempty"` - Environment map[string]string `json:"environment,omitempty"` - Timeout string `json:"timeout,omitempty"` - Retry Retry `json:"retry,omitempty"` - Inputs []string `json:"inputs,omitempty"` - Outputs []string `json:"outputs,omitempty"` - Artifacts []string `json:"artifacts,omitempty"` - DependsOn []string `json:"depends_on,omitempty"` - Exclusive bool `json:"exclusive,omitempty"` - EvidenceClass string `json:"evidence_class,omitempty"` - Configuration map[string]any `json:"configuration,omitempty"` - Extra map[string]any `json:"extra,omitempty"` -} - -var jsonMarshal = json.Marshal - -// CanonicalJSON returns deterministic JSON bytes. -func CanonicalJSON(v any) ([]byte, error) { - return jsonMarshal(v) -} - -// HashBytes returns sha256 hex of data. -func HashBytes(data []byte) string { - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) -} - -// ComputeHashes fills requirement and obligation hashes and document hash. -func (d *Document) ComputeHashes() error { - sort.SliceStable(d.Requirements, func(i, j int) bool { - return d.Requirements[i].ID < d.Requirements[j].ID - }) - for i := range d.Requirements { - r := &d.Requirements[i] - for j := range r.Obligations { - o := &r.Obligations[j] - clone := *o - clone.Hash = "" - b, err := CanonicalJSON(clone) - if err != nil { - return err - } - o.Hash = HashBytes(b) - } - clone := *r - clone.Hash = "" - b, err := CanonicalJSON(clone) - if err != nil { - return err - } - r.Hash = HashBytes(b) - } - clone := *d - clone.Hash = "" - b, err := CanonicalJSON(clone) - if err != nil { - return err - } - d.Hash = HashBytes(b) - return nil -} - -// ActiveRequirements returns requirements with status active. -func (d *Document) ActiveRequirements() []Requirement { - out := make([]Requirement, 0, len(d.Requirements)) - for _, r := range d.Requirements { - if r.Status == "active" { - out = append(out, r) - } - } - return out -} - -// RequirementByID finds a requirement by id. -func (d *Document) RequirementByID(id string) *Requirement { - for i := range d.Requirements { - if d.Requirements[i].ID == id { - return &d.Requirements[i] - } - } - return nil -} - -// BuildVerificationPlan constructs and hashes a canonical plan. -func BuildVerificationPlan(document *Document, requirements []Requirement) (*VerificationPlan, error) { - plan := &VerificationPlan{ - SchemaVersion: SchemaVersion, IRHash: document.Hash, - Requirements: make([]PlanRequirement, 0, len(requirements)), - } - for _, requirement := range requirements { - item := PlanRequirement{ID: requirement.ID, Hash: requirement.Hash} - for _, obligation := range requirement.Obligations { - counter := 0 - item.Obligations = append(item.Obligations, PlanObligation{ - ID: obligation.ID, Hash: obligation.Hash, - Verify: normalizeVerifierIDs(obligation.Verify, &counter), - }) - } - plan.Requirements = append(plan.Requirements, item) - } - sort.SliceStable(plan.Requirements, func(i, j int) bool { - return plan.Requirements[i].ID < plan.Requirements[j].ID - }) - clone := *plan - clone.Hash = "" - raw, err := CanonicalJSON(clone) - if err != nil { - return nil, err - } - plan.Hash = HashBytes(raw) - return plan, nil -} - -func normalizeVerifierIDs(node VerifyNode, counter *int) VerifyNode { - output := node - if node.Provider != nil { - spec := *node.Provider - if spec.ID == "" { - *counter++ - spec.ID = spec.Provider + "#" + fmt.Sprint(*counter) - } - output.Provider = &spec - } - for index, child := range node.All { - if index == 0 { - output.All = make([]VerifyNode, len(node.All)) - } - output.All[index] = normalizeVerifierIDs(child, counter) - } - for index, child := range node.Any { - if index == 0 { - output.Any = make([]VerifyNode, len(node.Any)) - } - output.Any[index] = normalizeVerifierIDs(child, counter) - } - if node.Not != nil { - child := normalizeVerifierIDs(*node.Not, counter) - output.Not = &child - } - return output -} diff --git a/internal/ir/ir_coverage_test.go b/internal/ir/ir_coverage_test.go deleted file mode 100644 index 07509c9..0000000 --- a/internal/ir/ir_coverage_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package ir_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/ir" -) - -func TestRequirementByIDMissingAndCanonical(t *testing.T) { - doc := &ir.Document{SchemaVersion: 1, Project: "p"} - if doc.RequirementByID("x") != nil { - t.Fatal("expected nil") - } - b, err := ir.CanonicalJSON(map[string]any{"a": 1}) - if err != nil || len(b) == 0 { - t.Fatal(err) - } - if ir.HashBytes(b) == "" { - t.Fatal("hash") - } - doc.Requirements = []ir.Requirement{{ - ID: "REQ-1", Status: "active", - Obligations: []ir.Obligation{{ID: "O", Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command", Run: "true"}}}}, - }} - if err := doc.ComputeHashes(); err != nil { - t.Fatal(err) - } - if doc.Requirements[0].Hash == "" || doc.Requirements[0].Obligations[0].Hash == "" { - t.Fatal("hashes") - } -} - -func TestComputeHashesErrors(t *testing.T) { - doc := &ir.Document{SchemaVersion: 1, Project: "p", Requirements: []ir.Requirement{{ - ID: "R", Obligations: []ir.Obligation{{ID: "O", Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Extra: map[string]any{"c": make(chan int)}, - }}}}, - }}} - if err := doc.ComputeHashes(); err == nil { - t.Fatal("obl hash") - } - doc = &ir.Document{SchemaVersion: 1, Project: "p", Requirements: []ir.Requirement{{ - ID: "R", AppliesTo: ir.AppliesTo{}, Constraints: nil, - // put chan on requirement via Boundaries? can't. Use Tags? no. - // Obligation ok, but requirement-level: Owners is []string. - // Provider Extra on obligation fails first. - }}} - doc = &ir.Document{SchemaVersion: 1, Project: "p", Requirements: []ir.Requirement{{ - ID: "R", Obligations: []ir.Obligation{{ID: "O"}}, - }}} - // document-level: after obl+req hash, document marshal - need chan at doc level - // Document only has Requirements - if requirement has something unmarshalable after obl hashed... - // Actually after obl hash succeeds, req clone includes Obligations with Hash set - still has Extra chan - doc = &ir.Document{SchemaVersion: 1, Project: "p", Requirements: []ir.Requirement{{ - ID: "R", Obligations: []ir.Obligation{{ID: "O", Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Extra: map[string]any{"c": make(chan int)}, - }}}}, - }}} - if err := doc.ComputeHashes(); err == nil { - t.Fatal("expected error") - } -} diff --git a/internal/ir/ir_internal_test.go b/internal/ir/ir_internal_test.go deleted file mode 100644 index 1ff4087..0000000 --- a/internal/ir/ir_internal_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package ir - -import ( - "errors" - "testing" -) - -func TestComputeHashesMarshalStages(t *testing.T) { - doc := &Document{SchemaVersion: 1, Project: "p", Requirements: []Requirement{{ - ID: "R", Obligations: []Obligation{{ID: "O"}}, - }}} - old := jsonMarshal - defer func() { jsonMarshal = old }() - n := 0 - jsonMarshal = func(v any) ([]byte, error) { - n++ - if n == 1 { - return nil, errors.New("obl") - } - return old(v) - } - if err := doc.ComputeHashes(); err == nil { - t.Fatal("obl") - } - n = 0 - jsonMarshal = func(v any) ([]byte, error) { - n++ - if n == 2 { - return nil, errors.New("req") - } - return old(v) - } - if err := doc.ComputeHashes(); err == nil { - t.Fatal("req") - } - n = 0 - jsonMarshal = func(v any) ([]byte, error) { - n++ - if n == 3 { - return nil, errors.New("doc") - } - return old(v) - } - if err := doc.ComputeHashes(); err == nil { - t.Fatal("doc") - } -} - -func TestBuildVerificationPlanNormalizesAndHashes(t *testing.T) { - document := &Document{SchemaVersion: 1, Project: "p", Hash: "ir", Requirements: []Requirement{ - { - ID: "REQ-2", Hash: "r2", - Obligations: []Obligation{{ - ID: "O2", Hash: "o2", - Verify: VerifyNode{ - All: []VerifyNode{{Provider: &ProviderSpec{Provider: "command"}}}, - Any: []VerifyNode{{Provider: &ProviderSpec{Provider: "json", ID: "named"}}}, - Not: &VerifyNode{Provider: &ProviderSpec{Provider: "manual"}}, - }, - }}, - }, - {ID: "REQ-1", Hash: "r1"}, - }} - plan, err := BuildVerificationPlan(document, document.Requirements) - if err != nil { - t.Fatal(err) - } - if plan.Hash == "" || plan.Requirements[0].ID != "REQ-1" { - t.Fatalf("%+v", plan) - } - node := plan.Requirements[1].Obligations[0].Verify - if node.All[0].Provider.ID != "command#1" || node.Any[0].Provider.ID != "named" || - node.Not.Provider.ID != "manual#2" { - t.Fatalf("%+v", node) - } - - old := jsonMarshal - jsonMarshal = func(any) ([]byte, error) { return nil, errors.New("plan") } - defer func() { jsonMarshal = old }() - if _, err := BuildVerificationPlan(document, document.Requirements); err == nil { - t.Fatal("plan marshal error ignored") - } -} diff --git a/internal/ir/ir_test.go b/internal/ir/ir_test.go deleted file mode 100644 index 15200f7..0000000 --- a/internal/ir/ir_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package ir_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/ir" -) - -func TestComputeHashesAndLookup(t *testing.T) { - doc := &ir.Document{ - SchemaVersion: 1, - Project: "p", - Requirements: []ir.Requirement{ - {ID: "REQ-2", Title: "b", Status: "active", Obligations: []ir.Obligation{{ID: "O"}}}, - {ID: "REQ-1", Title: "a", Status: "draft", Obligations: []ir.Obligation{{ID: "O"}}}, - }, - } - if err := doc.ComputeHashes(); err != nil { - t.Fatal(err) - } - if doc.Hash == "" || doc.Requirements[0].ID != "REQ-1" { - t.Fatalf("%+v", doc) - } - if doc.RequirementByID("REQ-2") == nil { - t.Fatal("missing") - } - if len(doc.ActiveRequirements()) != 1 { - t.Fatal(len(doc.ActiveRequirements())) - } -} diff --git a/internal/parser/parser.go b/internal/parser/parser.go deleted file mode 100644 index 8f9c460..0000000 --- a/internal/parser/parser.go +++ /dev/null @@ -1,648 +0,0 @@ -package parser - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - - "gopkg.in/yaml.v3" - - "github.com/hypertrial/intentci/internal/ir" -) - -var idRE = regexp.MustCompile(`^[A-Z][A-Z0-9_-]{1,31}-[0-9]{1,8}$`) -var yamlLineRE = regexp.MustCompile(`line ([0-9]+)`) - -// Diagnostic is a parse/compile diagnostic. -type Diagnostic struct { - Severity string `json:"severity,omitempty"` - Path string `json:"path,omitempty"` - Line int `json:"line,omitempty"` - Column int `json:"column,omitempty"` - Message string `json:"message"` -} - -func (d Diagnostic) Error() string { - location := d.Path - if d.Line > 0 { - location += fmt.Sprintf(":%d", d.Line) - if d.Column > 0 { - location += fmt.Sprintf(":%d", d.Column) - } - } - if location == "" { - return d.Message - } - return location + ": " + d.Message -} - -const ( - SeverityError = "error" - SeverityWarning = "warning" -) - -// ParseFile parses a single Markdown requirement file. -func ParseFile(path string) (ir.Requirement, []Diagnostic) { - data, err := os.ReadFile(path) - if err != nil { - return ir.Requirement{}, []Diagnostic{{Path: path, Message: err.Error()}} - } - return Parse(path, data) -} - -// Parse parses Markdown with YAML front matter. -func Parse(path string, data []byte) (ir.Requirement, []Diagnostic) { - var diags []Diagnostic - text := string(data) - fm, body, err := splitFrontMatter(text) - if err != nil { - return ir.Requirement{}, locateDiagnostics(text, []Diagnostic{{Path: path, Message: err.Error()}}) - } - - var meta struct { - ID string `yaml:"id"` - Title string `yaml:"title"` - Status string `yaml:"status"` - Priority string `yaml:"priority"` - Owners []string `yaml:"owners"` - DependsOn []string `yaml:"depends_on"` - AppliesTo struct { - Paths []string `yaml:"paths"` - Symbols []string `yaml:"symbols"` - } `yaml:"applies_to"` - Tags []string `yaml:"tags"` - Timeout string `yaml:"timeout"` - } - if err := decodeKnown(fm, &meta); err != nil { - return ir.Requirement{}, locateDiagnostics(text, []Diagnostic{{Path: path, Message: "front matter: " + err.Error()}}) - } - - req := ir.Requirement{ - ID: meta.ID, - Title: meta.Title, - Status: meta.Status, - Priority: meta.Priority, - Owners: meta.Owners, - DependsOn: meta.DependsOn, - AppliesTo: ir.AppliesTo{Paths: meta.AppliesTo.Paths, Symbols: meta.AppliesTo.Symbols}, - Tags: meta.Tags, - Timeout: meta.Timeout, - SourcePath: filepath.ToSlash(path), - } - - if req.ID == "" { - diags = append(diags, Diagnostic{Path: path, Message: "missing id"}) - } else if !idRE.MatchString(req.ID) { - diags = append(diags, Diagnostic{Path: path, Message: "invalid id format"}) - } - if req.Title == "" { - diags = append(diags, Diagnostic{Path: path, Message: "missing title"}) - } - if req.Status == "" { - diags = append(diags, Diagnostic{Path: path, Message: "missing status"}) - } else if !oneOf(req.Status, "draft", "active", "deprecated", "superseded", "disabled") { - diags = append(diags, Diagnostic{Path: path, Message: "invalid status " + fmt.Sprintf("%q", req.Status)}) - } - if req.Priority == "" { - diags = append(diags, Diagnostic{Path: path, Message: "missing priority"}) - } else if !oneOf(req.Priority, "required", "recommended", "informational") { - diags = append(diags, Diagnostic{Path: path, Message: "invalid priority " + fmt.Sprintf("%q", req.Priority)}) - } - - sections := splitSections(body) - req.Intent = strings.TrimSpace(sections["intent"]) - req.Rationale = strings.TrimSpace(sections["rationale"]) - if req.Intent == "" { - diags = append(diags, Diagnostic{Path: path, Message: "missing # Intent section"}) - } - - if raw, ok := sections["constraints"]; ok { - cons, d := parseConstraints(path, raw) - req.Constraints = cons - diags = append(diags, d...) - } - if raw, ok := sections["boundaries"]; ok { - b, d := parseBoundaries(path, raw) - req.Boundaries = b - diags = append(diags, d...) - } - if raw, ok := sections["obligations"]; ok { - obs, d := parseObligations(path, raw) - req.Obligations = obs - diags = append(diags, d...) - } - if len(req.Obligations) == 0 { - diags = append(diags, Diagnostic{Path: path, Message: "at least one obligation is required"}) - } - return req, locateDiagnostics(text, diags) -} - -func locateDiagnostics(source string, diagnostics []Diagnostic) []Diagnostic { - lines := strings.Split(source, "\n") - for index := range diagnostics { - diagnostic := &diagnostics[index] - if diagnostic.Line > 0 { - continue - } - if match := yamlLineRE.FindStringSubmatch(diagnostic.Message); len(match) == 2 { - fmt.Sscanf(match[1], "%d", &diagnostic.Line) - diagnostic.Line++ // account for the opening front-matter delimiter - diagnostic.Column = 1 - continue - } - token := strings.TrimSpace(strings.SplitN(diagnostic.Message, ":", 2)[0]) - for lineIndex, line := range lines { - column := strings.Index(line, token) - if token != "" && column >= 0 { - diagnostic.Line = lineIndex + 1 - diagnostic.Column = column + 1 - break - } - } - if diagnostic.Line == 0 { - diagnostic.Line = 1 - diagnostic.Column = 1 - } - } - return diagnostics -} - -func splitFrontMatter(text string) (string, string, error) { - text = strings.TrimPrefix(text, "\uFEFF") - if !strings.HasPrefix(text, "---\n") && !strings.HasPrefix(text, "---\r\n") { - return "", "", fmt.Errorf("missing YAML front matter") - } - rest := text[4:] - if strings.HasPrefix(text, "---\r\n") { - rest = text[5:] - } - idx := strings.Index(rest, "\n---\n") - crIdx := strings.Index(rest, "\n---\r\n") - end := -1 - sepLen := 5 - if idx >= 0 { - end = idx - } - if crIdx >= 0 && (end < 0 || crIdx < end) { - end = crIdx - sepLen = 6 - } - if end < 0 { - // closing --- at EOF (no trailing newline) - if i := strings.Index(rest, "\n---"); i >= 0 && rest[i+4:] == "" { - end = i - sepLen = 4 - } - } - if end < 0 { - return "", "", fmt.Errorf("unterminated YAML front matter") - } - fm := rest[:end] - body := rest[end+sepLen:] - return fm, body, nil -} - -func splitSections(body string) map[string]string { - out := map[string]string{} - lines := strings.Split(body, "\n") - var cur string - var buf []string - flush := func() { - if cur != "" { - out[cur] = strings.TrimSpace(strings.Join(buf, "\n")) - } - buf = nil - } - for _, line := range lines { - if strings.HasPrefix(line, "# ") { - flush() - cur = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(line, "# "))) - continue - } - buf = append(buf, line) - } - flush() - return out -} - -func parseConstraints(path, raw string) ([]ir.Constraint, []Diagnostic) { - var diags []Diagnostic - var out []ir.Constraint - // Expect ## Must / ## Must Not subsections with YAML lists - parts := splitH2(raw) - kinds := make([]string, 0, len(parts)) - for kind := range parts { - kinds = append(kinds, kind) - } - sort.Strings(kinds) - for _, kind := range kinds { - body := parts[kind] - var items []struct { - ID string `yaml:"id"` - Statement string `yaml:"statement"` - } - // body may be a yaml list directly - trimmed := strings.TrimSpace(body) - if trimmed == "" { - continue - } - if err := decodeKnown(trimmed, &items); err != nil { - diags = append(diags, Diagnostic{Path: path, Message: "constraints: " + err.Error()}) - continue - } - k := "must" - if strings.Contains(strings.ToLower(kind), "not") { - k = "must_not" - } - for _, it := range items { - out = append(out, ir.Constraint{ID: it.ID, Kind: k, Statement: it.Statement}) - } - } - return out, diags -} - -func oneOf(got string, values ...string) bool { - for _, value := range values { - if got == value { - return true - } - } - return false -} - -func splitH2(raw string) map[string]string { - out := map[string]string{} - lines := strings.Split(raw, "\n") - var cur string - var buf []string - flush := func() { - if cur != "" { - out[cur] = strings.Join(buf, "\n") - } - buf = nil - } - for _, line := range lines { - if strings.HasPrefix(line, "## ") { - flush() - cur = strings.TrimSpace(strings.TrimPrefix(line, "## ")) - continue - } - buf = append(buf, line) - } - flush() - return out -} - -func parseBoundaries(path, raw string) (ir.Boundaries, []Diagnostic) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```yaml") - raw = strings.TrimPrefix(raw, "```yml") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - var b ir.Boundaries - if err := decodeKnown(raw, &b); err != nil { - return b, []Diagnostic{{Path: path, Message: "boundaries: " + err.Error()}} - } - return b, nil -} - -type obligationYAML struct { - ID string `yaml:"id"` - Statement string `yaml:"statement"` - Required *bool `yaml:"required"` - Description string `yaml:"description"` - Rationale string `yaml:"rationale"` - EvidenceClass string `yaml:"evidence_class"` - ConfidenceThreshold *float64 `yaml:"confidence_threshold"` - Timeout string `yaml:"timeout"` - Retry ir.Retry `yaml:"retry"` - Platforms []string `yaml:"platforms"` - Tags []string `yaml:"tags"` - DependsOn []string `yaml:"depends_on"` - ManualReview bool `yaml:"manual_review"` - Severity string `yaml:"severity"` - Verify map[string]any `yaml:"verify"` -} - -func parseObligations(path, raw string) ([]ir.Obligation, []Diagnostic) { - raw = strings.TrimSpace(raw) - raw = strings.TrimPrefix(raw, "```yaml") - raw = strings.TrimPrefix(raw, "```yml") - raw = strings.TrimPrefix(raw, "```") - raw = strings.TrimSuffix(raw, "```") - raw = strings.TrimSpace(raw) - - var items []obligationYAML - if err := decodeKnown(raw, &items); err != nil { - return nil, []Diagnostic{{Path: path, Message: "obligations: " + err.Error()}} - } - var out []ir.Obligation - var diags []Diagnostic - for _, it := range items { - if it.ID == "" { - diags = append(diags, Diagnostic{Path: path, Message: "obligation missing id"}) - continue - } - if strings.TrimSpace(it.Statement) == "" { - diags = append(diags, Diagnostic{Path: path, Message: it.ID + ": missing statement"}) - } - req := true - if it.Required == nil { - diags = append(diags, Diagnostic{Path: path, Message: it.ID + ": missing required"}) - } else { - req = *it.Required - } - node, err := mapToVerify(it.Verify) - if err != nil { - diags = append(diags, Diagnostic{Path: path, Message: it.ID + ": " + err.Error()}) - continue - } - out = append(out, ir.Obligation{ - ID: it.ID, Statement: it.Statement, Required: req, - Description: it.Description, Rationale: it.Rationale, - EvidenceClass: it.EvidenceClass, ConfidenceThreshold: it.ConfidenceThreshold, - Timeout: it.Timeout, Retry: it.Retry, Platforms: it.Platforms, Tags: it.Tags, - DependsOn: it.DependsOn, ManualReview: it.ManualReview, Severity: it.Severity, - Verify: node, - }) - } - return out, diags -} - -func mapToVerify(m map[string]any) (ir.VerifyNode, error) { - if m == nil { - return ir.VerifyNode{}, fmt.Errorf("missing verify") - } - operators := 0 - for _, key := range []string{"all", "any", "not", "provider"} { - if _, ok := m[key]; ok { - operators++ - } - } - if operators != 1 { - return ir.VerifyNode{}, fmt.Errorf("verify must contain exactly one of all, any, not, or provider") - } - if v, ok := m["all"]; ok { - nodes, err := toNodeList(v) - if err != nil { - return ir.VerifyNode{}, err - } - if len(nodes) == 0 { - return ir.VerifyNode{}, fmt.Errorf("all must not be empty") - } - return ir.VerifyNode{All: nodes}, nil - } - if v, ok := m["any"]; ok { - nodes, err := toNodeList(v) - if err != nil { - return ir.VerifyNode{}, err - } - if len(nodes) == 0 { - return ir.VerifyNode{}, fmt.Errorf("any must not be empty") - } - return ir.VerifyNode{Any: nodes}, nil - } - if v, ok := m["not"]; ok { - childMap, ok := v.(map[string]any) - if !ok { - return ir.VerifyNode{}, fmt.Errorf("not must be a mapping") - } - child, err := mapToVerify(childMap) - if err != nil { - return ir.VerifyNode{}, err - } - return ir.VerifyNode{Not: &child}, nil - } - spec, err := toProvider(m) - if err != nil { - return ir.VerifyNode{}, err - } - return ir.VerifyNode{Provider: &spec}, nil -} - -func toNodeList(v any) ([]ir.VerifyNode, error) { - arr, ok := v.([]any) - if !ok { - return nil, fmt.Errorf("expected list") - } - out := make([]ir.VerifyNode, 0, len(arr)) - for _, item := range arr { - m, ok := item.(map[string]any) - if !ok { - return nil, fmt.Errorf("verify item must be a mapping") - } - // leaf provider or nested - if _, has := m["provider"]; has { - spec, err := toProvider(m) - if err != nil { - return nil, err - } - out = append(out, ir.VerifyNode{Provider: &spec}) - continue - } - n, err := mapToVerify(m) - if err != nil { - return nil, err - } - out = append(out, n) - } - return out, nil -} - -func toProvider(m map[string]any) (ir.ProviderSpec, error) { - spec := ir.ProviderSpec{} - providerName, err := requiredStringField(m, "provider") - if err != nil { - return spec, fmt.Errorf("provider name required") - } - spec.Provider = providerName - for name, destination := range map[string]*string{ - "id": &spec.ID, "run": &spec.Run, "report": &spec.Report, "prompt": &spec.Prompt, - "working_directory": &spec.WorkingDirectory, "timeout": &spec.Timeout, - "evidence_class": &spec.EvidenceClass, - } { - if err := optionalStringField(m, name, destination); err != nil { - return spec, err - } - } - for name, destination := range map[string]*map[string]any{ - "result": &spec.Result, "expect": &spec.Expect, "assert": &spec.Assert, - "match": &spec.Match, "allow": &spec.Allow, "configuration": &spec.Configuration, - } { - if err := optionalMapField(m, name, destination); err != nil { - return spec, err - } - } - for name, destination := range map[string]*[]string{ - "inherit_environment": &spec.InheritEnv, "allowed": &spec.Allowed, - "forbidden": &spec.Forbidden, "paths": &spec.Paths, "inputs": &spec.Inputs, - "outputs": &spec.Outputs, "artifacts": &spec.Artifacts, "depends_on": &spec.DependsOn, - } { - if err := optionalStringsField(m, name, destination); err != nil { - return spec, err - } - } - if v, ok := m["environment"]; ok { - env, err := stringMap(v) - if err != nil { - return spec, err - } - spec.Environment = env - } - if v, ok := m["retry"]; ok { - retry, err := retryValue(v) - if err != nil { - return spec, err - } - spec.Retry = retry - } - if raw, ok := m["exclusive"]; ok { - value, valid := raw.(bool) - if !valid { - return spec, fmt.Errorf("provider field %q must be a boolean", "exclusive") - } - spec.Exclusive = value - } - known := map[string]bool{ - "provider": true, "id": true, "run": true, "report": true, - "result": true, "expect": true, "assert": true, "match": true, "allow": true, - "allowed": true, "forbidden": true, "paths": true, - "prompt": true, "working_directory": true, "inherit_environment": true, - "environment": true, "timeout": true, "retry": true, "inputs": true, - "outputs": true, "artifacts": true, "depends_on": true, "exclusive": true, - "evidence_class": true, "configuration": true, - } - for k := range m { - if !known[k] { - return spec, fmt.Errorf("unknown provider field %q", k) - } - } - return spec, nil -} - -func requiredStringField(values map[string]any, name string) (string, error) { - raw, ok := values[name] - if !ok { - return "", fmt.Errorf("provider field %q is required", name) - } - value, ok := raw.(string) - if !ok || value == "" { - return "", fmt.Errorf("provider field %q must be a non-empty string", name) - } - return value, nil -} - -func optionalStringField(values map[string]any, name string, destination *string) error { - raw, ok := values[name] - if !ok { - return nil - } - if !isScalar(raw) { - return fmt.Errorf("provider field %q must be a string", name) - } - *destination = scalarString(raw) - return nil -} - -func optionalMapField(values map[string]any, name string, destination *map[string]any) error { - raw, ok := values[name] - if !ok { - return nil - } - value, ok := raw.(map[string]any) - if !ok { - return fmt.Errorf("provider field %q must be a mapping", name) - } - *destination = value - return nil -} - -func optionalStringsField(values map[string]any, name string, destination *[]string) error { - raw, ok := values[name] - if !ok { - return nil - } - items, ok := raw.([]any) - if !ok { - return fmt.Errorf("provider field %q must be a string list", name) - } - output := make([]string, 0, len(items)) - for _, rawItem := range items { - if !isScalar(rawItem) { - return fmt.Errorf("provider field %q must contain only strings", name) - } - output = append(output, scalarString(rawItem)) - } - *destination = output - return nil -} - -func decodeKnown(raw string, out any) error { - dec := yaml.NewDecoder(strings.NewReader(raw)) - dec.KnownFields(true) - return dec.Decode(out) -} - -func stringMap(v any) (map[string]string, error) { - raw, ok := v.(map[string]any) - if !ok { - return nil, fmt.Errorf("environment must be a mapping") - } - out := make(map[string]string, len(raw)) - for key, value := range raw { - if !isScalar(value) { - return nil, fmt.Errorf("environment value %q must be a string", key) - } - out[key] = scalarString(value) - } - return out, nil -} - -func isScalar(value any) bool { - switch value.(type) { - case string, bool, int, int64, float64: - return true - default: - return false - } -} - -func retryValue(v any) (ir.Retry, error) { - raw, _ := yaml.Marshal(v) - var retry ir.Retry - if err := decodeKnown(string(raw), &retry); err != nil { - return ir.Retry{}, fmt.Errorf("retry: %w", err) - } - return retry, nil -} - -// scalarString coerces YAML scalars (including unquoted true/false/numbers) to strings. -func scalarString(v any) string { - switch t := v.(type) { - case string: - return t - case bool: - if t { - return "true" - } - return "false" - case int: - return fmt.Sprintf("%d", t) - case int64: - return fmt.Sprintf("%d", t) - case float64: - if t == float64(int64(t)) { - return fmt.Sprintf("%d", int64(t)) - } - return fmt.Sprintf("%v", t) - default: - if v == nil { - return "" - } - return fmt.Sprintf("%v", t) - } -} diff --git a/internal/parser/parser_coverage_test.go b/internal/parser/parser_coverage_test.go deleted file mode 100644 index 1958f0e..0000000 --- a/internal/parser/parser_coverage_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package parser_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/hypertrial/intentci/internal/parser" -) - -func TestDiagnosticError(t *testing.T) { - d := parser.Diagnostic{Message: "msg"} - if d.Error() != "msg" { - t.Fatal(d.Error()) - } - d.Path = "a.md" - if d.Error() != "a.md: msg" { - t.Fatal(d.Error()) - } -} - -func TestParseFileMissing(t *testing.T) { - _, diags := parser.ParseFile(filepath.Join(t.TempDir(), "nope.md")) - if len(diags) == 0 { - t.Fatal("expected read error") - } -} - -func TestParseFrontMatterVariants(t *testing.T) { - // CRLF front matter - raw := "---\r\nid: REQ-1\ntitle: t\nstatus: active\npriority: required\n---\r\n# Intent\n\nx\n\n# Obligations\n\n```yaml\n- id: O\n verify:\n provider: command\n id: c\n run: \"true\"\n```\n" - _, diags := parser.Parse("cr.md", []byte(raw)) - _ = diags - - // unterminated - _, diags = parser.Parse("u.md", []byte("---\nid: x\n")) - if len(diags) == 0 { - t.Fatal("unterminated") - } - - // bad yaml fm - _, diags = parser.Parse("b.md", []byte("---\n:\n---\n# Intent\nx\n")) - if len(diags) == 0 { - t.Fatal("bad yaml") - } - - // trailing --- without newline after - _, _ = parser.Parse("t.md", []byte("---\nid: REQ-99\ntitle: t\nstatus: active\npriority: required\n---")) -} - -func TestParseMissingFieldsAndSections(t *testing.T) { - _, diags := parser.Parse("m.md", []byte("---\nid: BAD\n---\n# Rationale\nr\n")) - if len(diags) < 4 { - t.Fatalf("diags=%v", diags) - } -} - -func TestParseConstraintsBoundariesObligationsErrors(t *testing.T) { - body := `--- -id: REQ-X-1 -title: t -status: active -priority: required ---- - -# Intent - -intent - -# Constraints - -## Must - -not: valid: yaml: [ - -## Must Not - -- id: C2 - statement: s - -# Boundaries - -` + "```yaml" + ` -allowed: [ -` + "```" + ` - -# Obligations - -` + "```yaml" + ` -- id: "" - verify: {} -- id: O1 - required: false - verify: - all: notalist -- id: O2 - verify: - any: - - notamap -- id: O3 - verify: - not: [] -- id: O4 - verify: - not: - provider: command - run: "true" -- id: O5 - verify: - all: - - provider: 1 -- id: O6 - verify: - all: - - all: - - provider: command - id: nested - run: "true" - report: r - result: {equals: 0} - expect: {changed: false} - assert: {ok: true} - allowed: [a] - forbidden: [b] - paths: [c] - extra_key: 1 -- id: O7 - verify: - provider: command - id: leaf - run: "true" -` + "```" + ` -` - req, diags := parser.Parse("c.md", []byte(body)) - if req.ID != "REQ-X-1" { - t.Fatal(req.ID) - } - if len(diags) == 0 { - t.Fatal("expected diags") - } -} - -func TestParseVerifyBranches(t *testing.T) { - mk := func(verify string) string { - return `--- -id: REQ-Y-1 -title: t -status: active -priority: required ---- - -# Intent - -i - -# Obligations - -` + "```yml" + ` -- id: O - verify: -` + indent(verify, " ") + ` -` + "```" + ` -` - } - cases := []string{ - "all:\n - provider: command\n run: \"true\"\n", - "any:\n - provider: command\n id: a\n run: \"true\"\n", - "not:\n provider: command\n id: n\n run: \"true\"\n", - "provider: command\n id: p\n run: \"true\"\n", - "foo: 1\n", - } - for _, c := range cases { - _, diags := parser.Parse("v.md", []byte(mk(c))) - _ = diags - } -} - -func indent(s, prefix string) string { - lines := strings.Split(strings.TrimRight(s, "\n"), "\n") - for i, l := range lines { - lines[i] = prefix + l - } - return strings.Join(lines, "\n") -} - -func TestParseWriteAndReload(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "ok.md") - body := `--- -id: REQ-Z-1 -title: t -status: active -priority: required ---- - -# Intent - -ok - -# Boundaries - -allowed: - - a/** -forbidden: - - b/** - -# Obligations - -` + "```" + ` -- id: O - statement: s - required: true - verify: - all: - - provider: command - id: c - run: "true" -` + "```" + ` -` - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - req, diags := parser.ParseFile(path) - if len(diags) != 0 { - t.Fatalf("%v", diags) - } - if req.ID != "REQ-Z-1" { - t.Fatal(req) - } -} - -func TestParseBOMAndEmptyConstraint(t *testing.T) { - raw := "\uFEFF---\nid: REQ-BOM-1\ntitle: t\nstatus: active\npriority: required\n---\n# Intent\ni\n\n# Constraints\n\n## Must\n\n\n# Obligations\n\n```yaml\n- id: O\n verify:\n provider: command\n run: \"true\"\n```\n" - _, diags := parser.Parse("bom.md", []byte(raw)) - _ = diags -} diff --git a/internal/parser/parser_internal_test.go b/internal/parser/parser_internal_test.go deleted file mode 100644 index 8286e74..0000000 --- a/internal/parser/parser_internal_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package parser - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/ir" -) - -func TestSplitFrontMatterCRLFAndMapToVerify(t *testing.T) { - fm, body, err := splitFrontMatter("---\r\nid: x\n---\r\nbody") - if err != nil || fm == "" { - t.Fatalf("%q %q %v", fm, body, err) - } - _, _, err = splitFrontMatter("---\nid: x\n---\r\nbody") - if err != nil { - t.Fatal(err) - } - // EOF closing marker - fm, body, err = splitFrontMatter("---\nid: x\n---") - if err != nil || body != "" { - t.Fatalf("%q %q %v", fm, body, err) - } - _, err = mapToVerify(nil) - if err == nil { - t.Fatal("missing verify") - } - _, err = mapToVerify(map[string]any{"not": map[string]any{"nope": 1}}) - if err == nil { - t.Fatal("not child") - } - _, err = mapToVerify(map[string]any{"provider": 1}) - if err == nil { - t.Fatal("provider name") - } - _, err = toNodeList([]any{map[string]any{"all": "bad"}}) - if err == nil { - t.Fatal("nested") - } - n, err := mapToVerify(map[string]any{"provider": "command", "run": "true"}) - if err != nil || n.Provider == nil { - t.Fatal(err) - } - _ = ir.VerifyNode{} -} - -func TestParseMissingID(t *testing.T) { - _, diags := Parse("x.md", []byte("---\ntitle: t\nstatus: active\npriority: required\n---\n# Intent\ni\n\n# Obligations\n\n```yaml\n- id: O\n verify:\n provider: command\n run: \"true\"\n```\n")) - found := false - for _, d := range diags { - if d.Message == "missing id" { - found = true - } - } - if !found { - t.Fatalf("%v", diags) - } -} diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go deleted file mode 100644 index 2fe1c39..0000000 --- a/internal/parser/parser_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package parser_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/parser" -) - -const sample = `--- -id: REQ-AUTH-001 -title: Existing customers can authenticate -status: active -priority: required -owners: - - platform-auth -depends_on: [] -applies_to: - paths: - - src/auth/** -tags: - - authentication ---- - -# Intent - -Existing customers must authenticate. - -# Rationale - -Accounts require auth. - -# Constraints - -## Must - -- id: CON-001 - statement: Reuse the existing session store. - -## Must Not - -- id: CON-002 - statement: Do not modify migrations. - -# Boundaries - -` + "```yaml" + ` -allowed: - - src/auth/** -forbidden: - - migrations/** -` + "```" + ` - -# Obligations - -` + "```yaml" + ` -- id: OBL-001 - statement: Valid credentials create a session. - required: true - verify: - all: - - provider: command - id: auth-valid-test - run: true - result: - type: exit_code - equals: 0 -- id: OBL-002 - statement: No migration change. - required: true - verify: - all: - - provider: boundary - id: no-migration - forbidden: - - migrations/** -` + "```" + ` -` - -func TestParseSample(t *testing.T) { - req, diags := parser.Parse("req.md", []byte(sample)) - if len(diags) != 0 { - t.Fatalf("diags=%v", diags) - } - if req.ID != "REQ-AUTH-001" || len(req.Obligations) != 2 { - t.Fatalf("%+v", req) - } - if req.Obligations[0].Verify.All[0].Provider.Provider != "command" { - t.Fatalf("%+v", req.Obligations[0]) - } -} - -func TestParseFileAndErrors(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bad.md") - if err := os.WriteFile(path, []byte("no front matter"), 0o644); err != nil { - t.Fatal(err) - } - _, diags := parser.ParseFile(path) - if len(diags) == 0 { - t.Fatal("expected diags") - } - _, diags = parser.Parse("x.md", []byte("---\nid: bad\ntitle: t\nstatus: active\npriority: required\n---\n# Intent\n\nx\n")) - if len(diags) == 0 { - t.Fatal("expected invalid id / missing obligations") - } -} - -func TestRejectsInvalidStatusAndPriority(t *testing.T) { - bad := `--- -id: REQ-001 -title: typo -status: activ -priority: requred ---- -# Intent -x -# Obligations -` + "```yaml" + ` -- id: OBL-001 - statement: x - required: true - verify: - provider: command - run: "true" -` + "```" - _, diags := parser.Parse("bad.md", []byte(bad)) - if len(diags) != 2 { - t.Fatalf("got diagnostics %v", diags) - } -} diff --git a/internal/parser/parser_v1_internal_test.go b/internal/parser/parser_v1_internal_test.go deleted file mode 100644 index e76670d..0000000 --- a/internal/parser/parser_v1_internal_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package parser - -import ( - "strings" - "testing" -) - -func TestV1DiagnosticLocations(t *testing.T) { - diagnostic := Diagnostic{Path: "r.md", Line: 2, Column: 3, Message: "bad"} - if diagnostic.Error() != "r.md:2:3: bad" { - t.Fatal(diagnostic.Error()) - } - located := locateDiagnostics("first\nsecond", []Diagnostic{{Line: 7, Message: "known"}}) - if located[0].Line != 7 { - t.Fatal(located) - } -} - -func TestV1VerifyConversionEdges(t *testing.T) { - for _, expression := range []map[string]any{ - {"all": []any{}}, - {"any": []any{}}, - } { - if _, err := mapToVerify(expression); err == nil { - t.Fatal(expression) - } - } - nodes, err := toNodeList([]any{map[string]any{ - "all": []any{map[string]any{"provider": "command", "run": "true"}}, - }}) - if err != nil || len(nodes) != 1 || len(nodes[0].All) != 1 { - t.Fatalf("%+v %v", nodes, err) - } -} - -func TestV1ProviderConversionEdges(t *testing.T) { - invalid := []map[string]any{ - {}, - {"provider": "command", "run": []any{"bad"}}, - {"provider": "command", "result": "bad"}, - {"provider": "command", "inputs": "bad"}, - {"provider": "command", "inputs": []any{map[string]any{"bad": true}}}, - {"provider": "command", "environment": "bad"}, - {"provider": "command", "environment": map[string]any{"X": []any{"bad"}}}, - {"provider": "command", "retry": map[string]any{"unknown": true}}, - {"provider": "command", "exclusive": "true"}, - {"provider": "command", "unknown": true}, - } - for _, values := range invalid { - if _, err := toProvider(values); err == nil { - t.Fatalf("invalid provider accepted: %#v", values) - } - } - - full := map[string]any{ - "provider": "command", "id": 1, "run": true, "report": "r", - "prompt": "p", "working_directory": ".", "timeout": "1s", "evidence_class": "deterministic", - "result": map[string]any{}, "expect": map[string]any{}, "assert": map[string]any{}, - "match": map[string]any{}, "allow": map[string]any{}, "configuration": map[string]any{}, - "inherit_environment": []any{"PATH"}, "allowed": []any{"a"}, "forbidden": []any{"b"}, - "paths": []any{"c"}, "inputs": []any{"d"}, "outputs": []any{"e"}, - "artifacts": []any{"f"}, "depends_on": []any{"other"}, - "environment": map[string]any{"BOOL": true, "COUNT": 1}, - "retry": map[string]any{"attempts": 2, "backoff": "1s"}, - "exclusive": true, - } - spec, err := toProvider(full) - if err != nil || spec.ID != "1" || spec.Run != "true" || !spec.Exclusive || - spec.Retry.Attempts != 2 || spec.Environment["BOOL"] != "true" { - t.Fatalf("%+v %v", spec, err) - } - - if _, err := requiredStringField(map[string]any{}, "provider"); err == nil { - t.Fatal("missing required string") - } - if err := optionalStringField(map[string]any{}, "x", new(string)); err != nil { - t.Fatal(err) - } - if err := optionalMapField(map[string]any{}, "x", new(map[string]any)); err != nil { - t.Fatal(err) - } - if err := optionalStringsField(map[string]any{}, "x", new([]string)); err != nil { - t.Fatal(err) - } -} - -func TestV1ScalarMapAndRetryEdges(t *testing.T) { - values, err := stringMap(map[string]any{"A": "x", "B": int64(2), "C": float64(3)}) - if err != nil || strings.Join([]string{values["A"], values["B"], values["C"]}, "") != "x23" { - t.Fatalf("%v %v", values, err) - } - if isScalar(struct{}{}) { - t.Fatal("struct treated as scalar") - } - if _, err := retryValue("invalid"); err == nil { - t.Fatal("scalar retry accepted") - } -} diff --git a/internal/parser/scalar_internal_test.go b/internal/parser/scalar_internal_test.go deleted file mode 100644 index 965ec19..0000000 --- a/internal/parser/scalar_internal_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package parser - -import "testing" - -func TestScalarStringAllBranches(t *testing.T) { - cases := []struct { - in any - want string - }{ - {"x", "x"}, - {true, "true"}, - {false, "false"}, - {int(7), "7"}, - {int64(8), "8"}, - {float64(9), "9"}, - {float64(1.25), "1.25"}, - {nil, ""}, - {struct{ A int }{1}, "{1}"}, - } - for _, tc := range cases { - if got := scalarString(tc.in); got != tc.want { - t.Fatalf("%v: got %q want %q", tc.in, got, tc.want) - } - } -} diff --git a/internal/parser/yaml_scalar_test.go b/internal/parser/yaml_scalar_test.go deleted file mode 100644 index 7dd128c..0000000 --- a/internal/parser/yaml_scalar_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package parser_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/parser" -) - -func TestUnquotedTrueRunCoerced(t *testing.T) { - const body = `--- -id: REQ-1 -title: t -status: active -priority: required ---- - -# Intent - -x - -# Obligations - -` + "```yaml" + ` -- id: OBL-001 - statement: s - required: true - verify: - all: - - provider: command - id: smoke - run: true - result: - type: exit_code - equals: 0 - - provider: command - id: num - run: 42 - report: 1.5 -` + "```" + ` -` - req, diags := parser.Parse("r.md", []byte(body)) - if len(diags) != 0 { - t.Fatalf("%v", diags) - } - if req.Obligations[0].Verify.All[0].Provider.Run != "true" { - t.Fatalf("run=%q", req.Obligations[0].Verify.All[0].Provider.Run) - } - if req.Obligations[0].Verify.All[1].Provider.Run != "42" { - t.Fatalf("num run=%q", req.Obligations[0].Verify.All[1].Provider.Run) - } -} - -func TestScalarStringBranches(t *testing.T) { - // Cover int64/float/nil/default via exported? use Parse extras. - // Direct coverage through false bool and nested list strings. - const body = `--- -id: REQ-2 -title: t -status: active -priority: required ---- - -# Intent - -x - -# Obligations - -` + "```yaml" + ` -- id: OBL-001 - statement: s - required: true - verify: - all: - - provider: command - id: f - run: false - result: {type: exit_code, equals: 0} -` + "```" + ` -` - req, diags := parser.Parse("r.md", []byte(body)) - if len(diags) != 0 { - t.Fatalf("%v", diags) - } - if req.Obligations[0].Verify.All[0].Provider.Run != "false" { - t.Fatalf("%q", req.Obligations[0].Verify.All[0].Provider.Run) - } -} diff --git a/internal/provider/boundary.go b/internal/provider/boundary.go deleted file mode 100644 index ac1df39..0000000 --- a/internal/provider/boundary.go +++ /dev/null @@ -1,89 +0,0 @@ -package provider - -import ( - "context" - "fmt" - "time" - - "github.com/bmatcuk/doublestar/v4" - - "github.com/hypertrial/intentci/internal/ir" -) - -// BoundaryProvider checks changed files against allowed/forbidden globs. -type BoundaryProvider struct{} - -func (p *BoundaryProvider) Name() string { return "boundary" } -func (p *BoundaryProvider) Version() string { return "1.0.0" } - -func (p *BoundaryProvider) Validate(spec ir.ProviderSpec) []Diagnostic { - if len(spec.Forbidden) == 0 && len(spec.Allowed) == 0 { - return []Diagnostic{{Message: "allowed or forbidden required"}} - } - return nil -} - -func (p *BoundaryProvider) Execute(ctx context.Context, req Request) Result { - _ = ctx - start := time.Now() - var violations []string - for _, f := range req.ChangedFiles { - for _, pat := range req.Spec.Forbidden { - ok, err := doublestar.Match(pat, f) - if err == nil && ok { - violations = append(violations, f) - break - } - } - } - if len(req.Spec.Allowed) > 0 { - for _, f := range req.ChangedFiles { - allowed := false - for _, pat := range req.Spec.Allowed { - ok, err := doublestar.Match(pat, f) - if err == nil && ok { - allowed = true - break - } - } - if !allowed { - // only flag if also not already in violations for forbidden-only semantics; - // allowed means changes outside allowed are violations - violations = append(violations, f) - } - } - } - // dedupe - violations = unique(violations) - passed := len(violations) == 0 - summary := "no boundary violations" - if !passed { - summary = fmt.Sprintf("boundary violations: %v", violations) - } - return Result{ - Provider: p.Name(), - ProviderVersion: p.Version(), - Status: "completed", - SecurityViolation: !passed, - DurationMS: time.Since(start).Milliseconds(), - Evidence: []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "boundary"), - Class: firstNonEmpty(req.Spec.EvidenceClass, req.EvidenceClass, "deterministic"), - Summary: summary, - Paths: violations, - Passed: boolPtr(passed), - }}, - } -} - -func unique(ss []string) []string { - seen := map[string]bool{} - var out []string - for _, s := range ss { - if !seen[s] { - seen[s] = true - out = append(out, s) - } - } - return out -} diff --git a/internal/provider/command.go b/internal/provider/command.go deleted file mode 100644 index beeecf4..0000000 --- a/internal/provider/command.go +++ /dev/null @@ -1,164 +0,0 @@ -package provider - -import ( - "context" - "fmt" - "os/exec" - "regexp" - "strings" - - "github.com/hypertrial/intentci/internal/ir" -) - -// CommandProvider runs a shell command. -type CommandProvider struct { - Exec func(ctx context.Context, name string, arg ...string) *exec.Cmd -} - -func (p *CommandProvider) Name() string { return "command" } -func (p *CommandProvider) Version() string { return "1.0.0" } - -func (p *CommandProvider) Validate(spec ir.ProviderSpec) []Diagnostic { - if spec.Run == "" { - return []Diagnostic{{Message: "run is required"}} - } - known := map[string]bool{"type": true, "equals": true, "stdout": true, "stderr": true} - for key := range spec.Result { - if !known[key] { - return []Diagnostic{{Message: fmt.Sprintf("unsupported result field %q", key)}} - } - } - if value, ok := spec.Result["type"]; ok && fmt.Sprint(value) != "exit_code" { - return []Diagnostic{{Message: "result.type must be exit_code"}} - } - if value, ok := spec.Result["equals"]; ok { - switch typed := value.(type) { - case int: - case float64: - if typed != float64(int(typed)) { - return []Diagnostic{{Message: "result.equals must be an integer"}} - } - default: - return []Diagnostic{{Message: "result.equals must be an integer"}} - } - } - for _, stream := range []string{"stdout", "stderr"} { - raw, ok := spec.Result[stream] - if !ok { - continue - } - rules, ok := raw.(map[string]any) - if !ok { - return []Diagnostic{{Message: stream + " expectation must be a mapping"}} - } - for key, value := range rules { - if key != "equals" && key != "contains" && key != "matches" { - return []Diagnostic{{Message: fmt.Sprintf("unsupported %s matcher %q", stream, key)}} - } - if key == "matches" { - if _, err := regexp.Compile(fmt.Sprint(value)); err != nil { - return []Diagnostic{{Message: fmt.Sprintf("%s matcher: %v", stream, err)}} - } - } - } - } - return nil -} - -func (p *CommandProvider) Execute(ctx context.Context, req Request) Result { - process := runProcess(ctx, req, "sh", []string{"-c", req.Spec.Run}, nil, p.Exec) - res := Result{ - Provider: p.Name(), - ProviderVersion: p.Version(), - Status: "completed", - DurationMS: process.EndedAt.Sub(process.StartedAt).Milliseconds(), - ExitCode: process.ExitCode, - SecurityViolation: process.SecurityViolation, - } - if req.RetainStdout { - res.Stdout = process.Stdout - } - if req.RetainStderr { - res.Stderr = process.Stderr - } - if process.TimedOut { - res.Status = "error" - res.Diagnostics = []string{fmt.Sprintf("timed out after %s", req.Timeout)} - res.Evidence = []Evidence{{ - ID: req.Spec.ID, Class: "deterministic", Summary: "command timed out", Passed: boolPtr(false), - }} - return res - } - if process.Err != nil && process.ExitCode == nil { - res.Status = "error" - res.Diagnostics = []string{process.Err.Error()} - res.Evidence = []Evidence{{ - ID: req.Spec.ID, Class: "deterministic", Summary: process.Err.Error(), Passed: boolPtr(false), - }} - return res - } - expectCode := 0 - if req.Spec.Result != nil { - if v, ok := req.Spec.Result["equals"]; ok { - switch t := v.(type) { - case int: - expectCode = t - case float64: - expectCode = int(t) - } - } - } - code := *process.ExitCode - passed := code == expectCode - summary := fmt.Sprintf("command exited %d", code) - if !passed { - summary = fmt.Sprintf("command exited %d, want %d", code, expectCode) - } - if passed { - if ok, detail := matchOutput("stdout", process.Stdout, req.Spec.Result["stdout"]); !ok { - passed, summary = false, detail - } else if ok, detail := matchOutput("stderr", process.Stderr, req.Spec.Result["stderr"]); !ok { - passed, summary = false, detail - } - } - res.Evidence = []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "command"), - Class: firstNonEmpty(req.Spec.EvidenceClass, req.EvidenceClass, "deterministic"), - Summary: summary, Passed: boolPtr(passed), - Data: map[string]any{"exit_code": code, "run": req.Spec.Run}, - StartedAt: process.StartedAt, CompletedAt: process.EndedAt, - }} - return res -} - -func matchOutput(name, got string, raw any) (bool, string) { - if raw == nil { - return true, "" - } - rules, ok := raw.(map[string]any) - if !ok { - return false, name + " expectation must be a mapping" - } - if want, ok := rules["equals"]; ok && got != fmt.Sprint(want) { - return false, fmt.Sprintf("%s did not equal %q", name, want) - } - if want, ok := rules["contains"]; ok && !strings.Contains(got, fmt.Sprint(want)) { - return false, fmt.Sprintf("%s did not contain %q", name, want) - } - if pattern, ok := rules["matches"]; ok { - matched, err := regexp.MatchString(fmt.Sprint(pattern), got) - if err != nil || !matched { - return false, fmt.Sprintf("%s did not match %q", name, pattern) - } - } - return true, "" -} - -func firstNonEmpty(ss ...string) string { - for _, s := range ss { - if s != "" { - return s - } - } - return "" -} diff --git a/internal/provider/external.go b/internal/provider/external.go deleted file mode 100644 index 9c0e7cf..0000000 --- a/internal/provider/external.go +++ /dev/null @@ -1,111 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/hypertrial/intentci/internal/ir" -) - -const externalProtocolVersion = "1.0" - -// ExternalProvider invokes intentci-provider-NAME using the v1 subprocess protocol. -type ExternalProvider struct { - ProviderName string - Path string -} - -func (p *ExternalProvider) Name() string { return p.ProviderName } -func (p *ExternalProvider) Version() string { return "external" } -func (p *ExternalProvider) Validate(ir.ProviderSpec) []Diagnostic { - return nil -} - -func (p *ExternalProvider) Execute(ctx context.Context, req Request) Result { - request := map[string]any{ - "protocol_version": externalProtocolVersion, - "run_id": req.RunID, "attempt_id": req.AttemptID, - "requirement_id": req.RequirementID, "obligation_id": req.ObligationID, - "repository": map[string]any{ - "root": req.Root, "commit": req.HeadCommit, "base_commit": req.BaseCommit, - "diff_hash": req.DiffHash, "changed_files": req.ChangedFiles, - }, - "verifier": req.Spec, - "configuration": req.Spec.Configuration, - "timeout_ms": req.Timeout.Milliseconds(), - } - raw, err := json.Marshal(request) - if err != nil { - return externalError(p, err, "") - } - process := runProcess(ctx, req, p.Path, nil, bytes.NewReader(raw), nil) - if process.SecurityViolation { - result := externalError(p, process.Err, process.Stderr) - result.SecurityViolation = true - return result - } - if process.TimedOut { - return externalError(p, fmt.Errorf("external provider timed out"), process.Stderr) - } - if process.Err != nil { - return externalError(p, fmt.Errorf("external provider: %w", process.Err), process.Stderr) - } - var response struct { - ProtocolVersion string `json:"protocol_version"` - Provider string `json:"provider"` - ProviderVersion string `json:"provider_version"` - Status string `json:"status"` - Evidence []Evidence `json:"evidence"` - Diagnostics []string `json:"diagnostics"` - Extra map[string]any `json:"extra"` - } - if err := json.Unmarshal([]byte(process.Stdout), &response); err != nil { - return externalError(p, fmt.Errorf("parse external provider response: %w", err), process.Stderr) - } - if major(response.ProtocolVersion) != major(externalProtocolVersion) { - return externalError(p, fmt.Errorf("incompatible external provider protocol %q", response.ProtocolVersion), process.Stderr) - } - if response.ProviderVersion == "" { - return externalError(p, fmt.Errorf("external provider omitted provider_version"), process.Stderr) - } - if response.Status != "completed" && response.Status != "error" && response.Status != "skipped" { - return externalError(p, fmt.Errorf("external provider returned invalid status %q", response.Status), process.Stderr) - } - result := Result{ - Provider: p.Name(), ProviderVersion: response.ProviderVersion, - Status: response.Status, Evidence: response.Evidence, - Diagnostics: response.Diagnostics, DurationMS: process.EndedAt.Sub(process.StartedAt).Milliseconds(), - ExitCode: process.ExitCode, Extra: response.Extra, - } - if req.RetainStdout { - result.Stdout = process.Stdout - } - if req.RetainStderr { - result.Stderr = process.Stderr - } - if strings.TrimSpace(process.Stderr) != "" { - result.Diagnostics = append(result.Diagnostics, strings.TrimSpace(process.Stderr)) - } - return result -} - -func externalError(p *ExternalProvider, err error, stderr string) Result { - diagnostics := []string{err.Error()} - if strings.TrimSpace(stderr) != "" { - diagnostics = append(diagnostics, strings.TrimSpace(stderr)) - } - return Result{ - Provider: p.Name(), ProviderVersion: p.Version(), Status: "error", - Diagnostics: diagnostics, - Evidence: []Evidence{{ - ID: p.Name(), Class: "deterministic", Summary: err.Error(), Passed: boolPtr(false), - }}, - } -} - -func major(version string) string { - return strings.SplitN(version, ".", 2)[0] -} diff --git a/internal/provider/external_v1_test.go b/internal/provider/external_v1_test.go deleted file mode 100644 index 3626ed1..0000000 --- a/internal/provider/external_v1_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package provider_test - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" -) - -func TestExternalProviderProtocol(t *testing.T) { - root := t.TempDir() - writeExecutable := func(name, body string) string { - t.Helper() - path := filepath.Join(root, name) - if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { - t.Fatal(err) - } - return path - } - valid := writeExecutable("valid", `cat >/dev/null -echo diagnostic >&2 -printf '%s' '{"protocol_version":"1.9","provider":"custom","provider_version":"2.3.4","status":"completed","evidence":[{"id":"e","class":"deterministic","summary":"ok","passed":true}],"future":"ignored"}'`) - external := &provider.ExternalProvider{ProviderName: "custom", Path: valid} - if external.Name() != "custom" || external.Version() != "external" || len(external.Validate(ir.ProviderSpec{})) != 0 { - t.Fatal("external metadata") - } - result := external.Execute(context.Background(), provider.Request{ - Root: root, Timeout: 10 * time.Second, RetainStdout: true, RetainStderr: true, - Spec: ir.ProviderSpec{WorkingDirectory: ".", Configuration: map[string]any{"x": true}}, - }) - if result.Status != "completed" || result.ProviderVersion != "2.3.4" || - len(result.Evidence) != 1 || !strings.Contains(result.Stderr, "diagnostic") || - len(result.Diagnostics) != 1 { - t.Fatalf("%+v", result) - } - - cases := []struct { - name string - body string - }{ - {"malformed", `cat >/dev/null; echo nope`}, - {"major", `cat >/dev/null; echo '{"protocol_version":"2.0","provider_version":"1","status":"completed"}'`}, - {"version", `cat >/dev/null; echo '{"protocol_version":"1.0","status":"completed"}'`}, - {"status", `cat >/dev/null; echo '{"protocol_version":"1.0","provider_version":"1","status":"bogus"}'`}, - {"exit", `cat >/dev/null; echo failed >&2; exit 3`}, - } - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - path := writeExecutable(testCase.name, testCase.body) - got := (&provider.ExternalProvider{ProviderName: testCase.name, Path: path}).Execute( - context.Background(), - provider.Request{Root: root, Timeout: 10 * time.Second, Spec: ir.ProviderSpec{WorkingDirectory: "."}}, - ) - if got.Status != "error" || len(got.Diagnostics) == 0 { - t.Fatalf("%+v", got) - } - }) - } - - slow := writeExecutable("slow", `sleep 2`) - timed := (&provider.ExternalProvider{ProviderName: "slow", Path: slow}).Execute( - context.Background(), - provider.Request{Root: root, Timeout: time.Millisecond, Spec: ir.ProviderSpec{WorkingDirectory: "."}}, - ) - if timed.Status != "error" { - t.Fatal(timed) - } - unsafe := external.Execute(context.Background(), provider.Request{ - Root: root, Timeout: 10 * time.Second, Spec: ir.ProviderSpec{WorkingDirectory: "../outside"}, - }) - if !unsafe.SecurityViolation { - t.Fatal(unsafe) - } -} - -func TestDynamicRegistryAndStableEnvironmentFingerprint(t *testing.T) { - root := t.TempDir() - executable := filepath.Join(root, "intentci-provider-demo") - if err := os.WriteFile(executable, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", root+string(os.PathListSeparator)+os.Getenv("PATH")) - registry := provider.DefaultRegistry() - if _, ok := registry.Get("demo"); !ok { - t.Fatal("dynamic provider not resolved") - } - if _, ok := registry.Get("../demo"); ok { - t.Fatal("unsafe provider name resolved") - } - first := provider.Request{ - RunID: "one", AttemptID: "a", Spec: ir.ProviderSpec{Environment: map[string]string{"FIXED": "yes"}}, - } - second := first - second.RunID = "two" - second.AttemptID = "b" - if provider.EnvironmentFingerprint(first) != provider.EnvironmentFingerprint(second) { - t.Fatal("run-specific variables contaminated the cache fingerprint") - } - second.Spec.Environment = map[string]string{"FIXED": "no"} - if provider.EnvironmentFingerprint(first) == provider.EnvironmentFingerprint(second) { - t.Fatal("explicit environment change did not invalidate fingerprint") - } -} diff --git a/internal/provider/gitdiff.go b/internal/provider/gitdiff.go deleted file mode 100644 index 32ac21f..0000000 --- a/internal/provider/gitdiff.go +++ /dev/null @@ -1,135 +0,0 @@ -package provider - -import ( - "context" - "fmt" - "time" - - "github.com/bmatcuk/doublestar/v4" - - "github.com/hypertrial/intentci/internal/ir" -) - -// GitDiffProvider asserts that specific paths did or did not change. -type GitDiffProvider struct{} - -func (p *GitDiffProvider) Name() string { return "git-diff" } -func (p *GitDiffProvider) Version() string { return "1.0.0" } - -func (p *GitDiffProvider) Validate(spec ir.ProviderSpec) []Diagnostic { - if len(spec.Paths) == 0 && len(spec.Forbidden) == 0 { - return []Diagnostic{{Message: "paths or forbidden required"}} - } - return nil -} - -func (p *GitDiffProvider) Execute(ctx context.Context, req Request) Result { - _ = ctx - start := time.Now() - patterns := append([]string{}, specPaths(req.Spec)...) - var hits []string - var matchedChanges []Change - for _, f := range req.ChangedFiles { - for _, pat := range patterns { - ok, err := doublestar.Match(pat, f) - if err == nil && ok { - hits = append(hits, f) - matchedChanges = append(matchedChanges, changeForPath(req.Changes, f)) - break - } - } - } - // default: forbidden paths must not change - expectUnchanged := true - if req.Spec.Expect != nil { - if v, ok := req.Spec.Expect["changed"].(bool); ok { - expectUnchanged = !v - } - } - passed := true - summary := "git-diff check passed" - if expectUnchanged { - passed = len(hits) == 0 - if !passed { - summary = fmt.Sprintf("unexpected changes: %v", hits) - } - } else { - passed = len(hits) > 0 - if !passed { - summary = "expected changes matching paths, found none" - } - } - if passed { - var reason string - passed, reason = evaluateChangeExpectations(req.Spec.Expect, matchedChanges) - if !passed { - summary = reason - } - } - return Result{ - Provider: p.Name(), - ProviderVersion: p.Version(), - Status: "completed", - DurationMS: time.Since(start).Milliseconds(), - Evidence: []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "git-diff"), - Class: firstNonEmpty(req.Spec.EvidenceClass, req.EvidenceClass, "deterministic"), - Summary: summary, Paths: hits, Passed: boolPtr(passed), - Data: map[string]any{"changes": matchedChanges}, - }}, - } -} - -func changeForPath(changes []Change, path string) Change { - for _, change := range changes { - if change.Path == path { - return change - } - } - return Change{Path: path, Status: "modified"} -} - -func evaluateChangeExpectations(expect map[string]any, changes []Change) (bool, string) { - if expect == nil { - return true, "" - } - statuses := stringValues(expect["status"]) - if single, ok := expect["status"].(string); ok { - statuses = []string{single} - } - additions, deletions := 0, 0 - for _, change := range changes { - additions += change.Additions - deletions += change.Deletions - if len(statuses) > 0 && !containsString(statuses, change.Status) { - return false, fmt.Sprintf("change %s has status %s", change.Path, change.Status) - } - } - for key, status := range map[string]string{ - "renamed": "renamed", "deleted": "deleted", "binary": "binary", - } { - expected, ok := expect[key].(bool) - if !ok { - continue - } - found := false - for _, change := range changes { - found = found || (status == "binary" && change.Binary) || change.Status == status - } - if found != expected { - return false, fmt.Sprintf("expected %s=%t", key, expected) - } - } - for key, got := range map[string]int{"max_additions": additions, "max_deletions": deletions} { - if maximum, ok := integer(expect[key]); ok && got > maximum { - return false, fmt.Sprintf("%s exceeded: %d > %d", key, got, maximum) - } - } - return true, "" -} - -func specPaths(spec ir.ProviderSpec) []string { - out := append([]string{}, spec.Paths...) - out = append(out, spec.Forbidden...) - return out -} diff --git a/internal/provider/json_provider.go b/internal/provider/json_provider.go deleted file mode 100644 index 4a04e54..0000000 --- a/internal/provider/json_provider.go +++ /dev/null @@ -1,245 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "fmt" - "os" - "sort" - "strconv" - "strings" - "time" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/security" -) - -// JSONProvider reads a JSON file and checks assert equality on a key path. -type JSONProvider struct{} - -func (p *JSONProvider) Name() string { return "json" } -func (p *JSONProvider) Version() string { return "1.0.0" } - -func (p *JSONProvider) Validate(spec ir.ProviderSpec) []Diagnostic { - if spec.Report == "" { - return []Diagnostic{{Message: "report required"}} - } - if len(spec.Assert) == 0 { - return []Diagnostic{{Message: "assert required"}} - } - if err := validateJSONAssert(spec.Assert); err != nil { - return []Diagnostic{{Message: err.Error()}} - } - return nil -} - -func (p *JSONProvider) Execute(ctx context.Context, req Request) Result { - _ = ctx - start := time.Now() - report, err := security.ResolveInside(req.Root, req.Spec.Report) - if err != nil { - return jsonError(p, req, start, err) - } - data, err := os.ReadFile(report) - if err != nil { - return jsonError(p, req, start, err) - } - var doc any - if err := json.Unmarshal(data, &doc); err != nil { - return Result{ - Provider: p.Name(), ProviderVersion: p.Version(), Status: "error", - DurationMS: time.Since(start).Milliseconds(), - Diagnostics: []string{err.Error()}, - } - } - passed := true - summary := "json assert passed" - if req.Spec.Assert != nil { - var err error - passed, summary, err = evaluateJSONAssert(doc, req.Spec.Assert) - if err != nil { - return jsonError(p, req, start, err) - } - } - return Result{ - Provider: p.Name(), ProviderVersion: p.Version(), Status: "completed", - DurationMS: time.Since(start).Milliseconds(), - Evidence: []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "json"), Class: firstNonEmpty(req.Spec.EvidenceClass, req.EvidenceClass, "deterministic"), - Summary: summary, Passed: boolPtr(passed), - }}, - } -} - -func jsonError(p *JSONProvider, req Request, start time.Time, err error) Result { - return Result{ - Provider: p.Name(), ProviderVersion: p.Version(), Status: "error", - DurationMS: time.Since(start).Milliseconds(), Diagnostics: []string{err.Error()}, - SecurityViolation: security.IsPathViolation(err), - Evidence: []Evidence{{ID: req.Spec.ID, Class: "deterministic", Summary: err.Error(), Passed: boolPtr(false)}}, - } -} - -func validateJSONAssert(assert map[string]any) error { - if assert == nil { - return nil - } - path, hasPath := assert["path"].(string) - if !hasPath { - return nil // legacy top-level equality map - } - if _, _, err := jsonPath(nil, path, true); err != nil { - return err - } - operators := 0 - for _, name := range []string{"exists", "equals", "not_equals", "gt", "gte", "lt", "lte"} { - if _, ok := assert[name]; ok { - operators++ - } - } - if operators != 1 { - return fmt.Errorf("json assert requires exactly one supported operator") - } - return nil -} - -func evaluateJSONAssert(doc any, assert map[string]any) (bool, string, error) { - if err := validateJSONAssert(assert); err != nil { - return false, "", err - } - path, pathMode := assert["path"].(string) - if !pathMode { - keys := make([]string, 0, len(assert)) - for key := range assert { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - got, exists := lookupMember(doc, key) - if !exists || fmt.Sprint(got) != fmt.Sprint(assert[key]) { - return false, fmt.Sprintf("assert %s: got %v want %v", key, got, assert[key]), nil - } - } - return true, "json assert passed", nil - } - got, exists, _ := jsonPath(doc, path, false) - if want, ok := assert["exists"]; ok { - expected, ok := want.(bool) - if !ok { - return false, "", fmt.Errorf("exists must be boolean") - } - return exists == expected, fmt.Sprintf("%s exists=%t", path, exists), nil - } - if !exists { - return false, path + " does not exist", nil - } - operator := "" - for _, candidate := range []string{"equals", "not_equals", "gt", "gte", "lt", "lte"} { - if _, ok := assert[candidate]; ok { - operator = candidate - break - } - } - want := assert[operator] - passed, err := compareJSON(got, want, operator) - return passed, fmt.Sprintf("%s %s %v (got %v)", path, operator, want, got), err -} - -func jsonPath(doc any, expression string, validateOnly bool) (any, bool, error) { - if expression == "" || expression[0] != '$' { - return nil, false, fmt.Errorf("unsupported JSONPath %q", expression) - } - current := doc - exists := true - for i := 1; i < len(expression); { - switch expression[i] { - case '.': - i++ - start := i - for i < len(expression) && (expression[i] == '_' || expression[i] == '-' || - expression[i] >= 'a' && expression[i] <= 'z' || - expression[i] >= 'A' && expression[i] <= 'Z' || - expression[i] >= '0' && expression[i] <= '9') { - i++ - } - if start == i { - return nil, false, fmt.Errorf("unsupported JSONPath %q", expression) - } - if !validateOnly && exists { - current, exists = lookupMember(current, expression[start:i]) - } - case '[': - end := strings.IndexByte(expression[i:], ']') - if end < 0 { - return nil, false, fmt.Errorf("unsupported JSONPath %q", expression) - } - end += i - index, err := strconv.Atoi(expression[i+1 : end]) - if err != nil || index < 0 { - return nil, false, fmt.Errorf("unsupported JSONPath %q", expression) - } - if !validateOnly && exists { - array, ok := current.([]any) - if !ok || index >= len(array) { - exists = false - } else { - current = array[index] - } - } - i = end + 1 - default: - return nil, false, fmt.Errorf("unsupported JSONPath %q", expression) - } - } - return current, exists, nil -} - -func lookupMember(doc any, key string) (any, bool) { - m, ok := doc.(map[string]any) - if !ok { - return nil, false - } - value, exists := m[key] - return value, exists -} - -func compareJSON(got, want any, operator string) (bool, error) { - if operator == "equals" { - return fmt.Sprint(got) == fmt.Sprint(want), nil - } - if operator == "not_equals" { - return fmt.Sprint(got) != fmt.Sprint(want), nil - } - left, leftOK := number(got) - right, rightOK := number(want) - if !leftOK || !rightOK { - return false, fmt.Errorf("%s requires numeric operands", operator) - } - switch operator { - case "gt": - return left > right, nil - case "gte": - return left >= right, nil - case "lt": - return left < right, nil - case "lte": - return left <= right, nil - default: - return false, fmt.Errorf("unsupported operator %q", operator) - } -} - -func number(value any) (float64, bool) { - switch typed := value.(type) { - case float64: - return typed, true - case float32: - return float64(typed), true - case int: - return float64(typed), true - case int64: - return float64(typed), true - default: - return 0, false - } -} diff --git a/internal/provider/junit.go b/internal/provider/junit.go deleted file mode 100644 index fa544e1..0000000 --- a/internal/provider/junit.go +++ /dev/null @@ -1,221 +0,0 @@ -package provider - -import ( - "context" - "encoding/xml" - "fmt" - "os" - "os/exec" - "time" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/security" -) - -// JUnitProvider runs a command and/or reads a JUnit XML report. -type JUnitProvider struct { - Exec func(ctx context.Context, name string, arg ...string) *exec.Cmd -} - -func (p *JUnitProvider) Name() string { return "junit" } -func (p *JUnitProvider) Version() string { return "1.0.0" } - -func (p *JUnitProvider) Validate(spec ir.ProviderSpec) []Diagnostic { - if spec.Report == "" { - return []Diagnostic{{Message: "report required"}} - } - return nil -} - -type junitTestSuites struct { - XMLName xml.Name `xml:"testsuites"` - Suites []junitTestSuite `xml:"testsuite"` -} - -type junitTestSuite struct { - XMLName xml.Name `xml:"testsuite"` - Name string `xml:"name,attr"` - Tests int `xml:"tests,attr"` - Failures int `xml:"failures,attr"` - Errors int `xml:"errors,attr"` - Cases []junitTestCase `xml:"testcase"` -} - -type junitTestCase struct { - Name string `xml:"name,attr"` - Classname string `xml:"classname,attr"` - Time float64 `xml:"time,attr"` - Failure *junitFail `xml:"failure"` - Error *junitFail `xml:"error"` - Skipped *junitFail `xml:"skipped"` -} - -type junitFail struct { - Message string `xml:"message,attr"` - Body string `xml:",chardata"` -} - -func (p *JUnitProvider) Execute(ctx context.Context, req Request) Result { - start := time.Now() - res := Result{Provider: p.Name(), ProviderVersion: p.Version(), Status: "completed"} - report := req.Spec.Report - if report != "" { - var err error - report, err = security.ResolveInside(req.Root, report) - if err != nil { - res.Status = "error" - res.Diagnostics = []string{err.Error()} - res.SecurityViolation = security.IsPathViolation(err) - return res - } - } - var commandErr error - if req.Spec.Run != "" { - if report == "" { - res.Status = "error" - res.Diagnostics = []string{"report path required after run"} - res.DurationMS = time.Since(start).Milliseconds() - return res - } - if err := os.Remove(report); err != nil && !os.IsNotExist(err) { - res.Status = "error" - res.Diagnostics = []string{"remove stale junit report: " + err.Error()} - res.DurationMS = time.Since(start).Milliseconds() - return res - } - process := runProcess(ctx, req, "sh", []string{"-c", req.Spec.Run}, nil, p.Exec) - commandErr = process.Err - res.SecurityViolation = process.SecurityViolation - res.ExitCode = process.ExitCode - if req.RetainStdout { - res.Stdout = process.Stdout - } - if req.RetainStderr { - res.Stderr = process.Stderr - } - if process.TimedOut { - res.Status = "error" - res.Diagnostics = []string{"junit command timed out"} - res.DurationMS = time.Since(start).Milliseconds() - res.Evidence = []Evidence{{ID: req.Spec.ID, Class: "deterministic", Summary: "timed out", Passed: boolPtr(false)}} - return res - } - } - if report == "" { - res.Status = "error" - res.Diagnostics = []string{"report path required after run"} - res.DurationMS = time.Since(start).Milliseconds() - return res - } - data, err := os.ReadFile(report) - if err != nil { - res.Status = "error" - res.Diagnostics = []string{err.Error()} - res.DurationMS = time.Since(start).Milliseconds() - res.Evidence = []Evidence{{ID: req.Spec.ID, Class: "deterministic", Summary: err.Error(), Passed: boolPtr(false)}} - return res - } - summaryData, parseErr := parseJUnitSummary(data) - if parseErr != nil { - res.Status = "error" - res.Diagnostics = []string{parseErr.Error()} - res.DurationMS = time.Since(start).Milliseconds() - return res - } - passed := summaryData.Failures == 0 && summaryData.Errors == 0 - if passed && commandErr != nil { - res.Status = "error" - res.Diagnostics = []string{"junit generator failed: " + commandErr.Error()} - res.DurationMS = time.Since(start).Milliseconds() - res.Evidence = []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "junit"), Class: "deterministic", - Summary: "generator failed despite passing report", Passed: boolPtr(false), - }} - return res - } - summary := fmt.Sprintf( - "junit: %d tests, %d failures, %d errors, %d skipped", - summaryData.Tests, summaryData.Failures, summaryData.Errors, summaryData.Skipped, - ) - var passedValue *bool - if summaryData.Tests == 0 { - passedValue = nil - summary = "junit: report contained no tests" - } else if summaryData.Skipped == summaryData.Tests { - passedValue = nil - summary = "junit: all tests were skipped" - } else { - passedValue = boolPtr(passed) - } - res.Evidence = []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "junit"), Class: firstNonEmpty(req.Spec.EvidenceClass, req.EvidenceClass, "deterministic"), - Summary: summary, Passed: passedValue, - Data: map[string]any{ - "tests": summaryData.Tests, "failures": summaryData.Failures, - "errors": summaryData.Errors, "skipped": summaryData.Skipped, - "duration_seconds": summaryData.Duration, "failure_messages": summaryData.Messages, - }, - }} - res.DurationMS = time.Since(start).Milliseconds() - return res -} - -func parseJUnit(data []byte) (failures, total int, err error) { - summary, err := parseJUnitSummary(data) - return summary.Failures + summary.Errors, summary.Tests, err -} - -type junitSummary struct { - Tests int - Failures int - Errors int - Skipped int - Duration float64 - Messages []string -} - -func parseJUnitSummary(data []byte) (junitSummary, error) { - var suites junitTestSuites - if err := xml.Unmarshal(data, &suites); err == nil && (len(suites.Suites) > 0 || suites.XMLName.Local == "testsuites") { - var summary junitSummary - for _, s := range suites.Suites { - addJUnitSuite(&summary, s) - } - return summary, nil - } - var suite junitTestSuite - if err := xml.Unmarshal(data, &suite); err != nil { - return junitSummary{}, fmt.Errorf("parse junit: %w", err) - } - var summary junitSummary - addJUnitSuite(&summary, suite) - return summary, nil -} - -func addJUnitSuite(summary *junitSummary, suite junitTestSuite) { - tests := suite.Tests - if tests == 0 { - tests = len(suite.Cases) - } - summary.Tests += tests - summary.Failures += suite.Failures - summary.Errors += suite.Errors - for _, testCase := range suite.Cases { - summary.Duration += testCase.Time - if testCase.Skipped != nil { - summary.Skipped++ - } - if testCase.Failure != nil { - if suite.Failures == 0 { - summary.Failures++ - } - summary.Messages = append(summary.Messages, firstNonEmpty(testCase.Failure.Message, testCase.Failure.Body)) - } - if testCase.Error != nil { - if suite.Errors == 0 { - summary.Errors++ - } - summary.Messages = append(summary.Messages, firstNonEmpty(testCase.Error.Message, testCase.Error.Body)) - } - } -} diff --git a/internal/provider/manual.go b/internal/provider/manual.go deleted file mode 100644 index ce033d0..0000000 --- a/internal/provider/manual.go +++ /dev/null @@ -1,28 +0,0 @@ -package provider - -import ( - "context" - - "github.com/hypertrial/intentci/internal/ir" -) - -// ManualProvider marks an obligation as requiring human review. -type ManualProvider struct{} - -func (p *ManualProvider) Name() string { return "manual" } -func (p *ManualProvider) Version() string { return "1.0.0" } - -func (p *ManualProvider) Validate(spec ir.ProviderSpec) []Diagnostic { return nil } - -func (p *ManualProvider) Execute(ctx context.Context, req Request) Result { - _ = ctx - return Result{ - Provider: p.Name(), ProviderVersion: p.Version(), Status: "completed", - DurationMS: 0, - Evidence: []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "manual"), Class: "human", - Summary: "manual review required", Passed: nil, - Data: map[string]any{"review_required": true}, - }}, - } -} diff --git a/internal/provider/process.go b/internal/provider/process.go deleted file mode 100644 index eaeb454..0000000 --- a/internal/provider/process.go +++ /dev/null @@ -1,63 +0,0 @@ -package provider - -import ( - "bytes" - "context" - "io" - "os/exec" - "time" - - "github.com/hypertrial/intentci/internal/security" -) - -type commandFactory func(context.Context, string, ...string) *exec.Cmd - -type processResult struct { - Stdout string - Stderr string - ExitCode *int - Err error - TimedOut bool - SecurityViolation bool - StartedAt time.Time - EndedAt time.Time -} - -func runProcess(ctx context.Context, req Request, name string, args []string, stdin io.Reader, factory commandFactory) processResult { - timeout := req.Timeout - if timeout <= 0 { - timeout = 10 * time.Minute - } - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - if factory == nil { - factory = exec.CommandContext - } - dir, err := security.ResolveInside(req.Root, firstNonEmpty(req.Spec.WorkingDirectory, ".")) - if err != nil { - return processResult{Err: err, SecurityViolation: security.IsPathViolation(err)} - } - cmd := factory(ctx, name, args...) - cmd.Dir = dir - cmd.Env = minimalEnvironment(req) - cmd.Stdin = stdin - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - started := time.Now().UTC() - err = cmd.Run() - ended := time.Now().UTC() - result := processResult{ - Stdout: stdout.String(), Stderr: stderr.String(), Err: err, - TimedOut: ctx.Err() == context.DeadlineExceeded, - StartedAt: started, EndedAt: ended, - } - code := 0 - if err == nil { - result.ExitCode = &code - } else if exitError, ok := err.(*exec.ExitError); ok { - code = exitError.ExitCode() - result.ExitCode = &code - } - return result -} diff --git a/internal/provider/provider.go b/internal/provider/provider.go deleted file mode 100644 index 2d74186..0000000 --- a/internal/provider/provider.go +++ /dev/null @@ -1,237 +0,0 @@ -package provider - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "os/exec" - "path" - "sort" - "strings" - "time" - - "github.com/hypertrial/intentci/internal/ir" -) - -// Diagnostic is a provider validation issue. -type Diagnostic struct { - Message string -} - -// Request is passed to a provider execution. -type Request struct { - RunID string - AttemptID string - ExecutionAttempt int - RequirementID string - ObligationID string - Root string - EvidenceDir string - BaseCommit string - HeadCommit string - DiffHash string - RequirementHash string - ObligationHash string - PlanHash string - EvidenceClass string - ConfidenceThreshold *float64 - ChangedFiles []string - Changes []Change - Spec ir.ProviderSpec - Timeout time.Duration - RetainStdout bool - RetainStderr bool -} - -// Change is the provider-facing repository change record. -type Change struct { - Path string - OldPath string - Status string - Additions int - Deletions int - Binary bool - OldMode string - NewMode string -} - -// Result is normalized provider output. -type Result struct { - Provider string `json:"provider"` - ProviderVersion string `json:"provider_version"` - Status string `json:"status"` // completed|error|skipped - Evidence []Evidence `json:"evidence"` - Diagnostics []string `json:"diagnostics,omitempty"` - Stdout string `json:"stdout,omitempty"` - Stderr string `json:"stderr,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` - DurationMS int64 `json:"duration_ms"` - FromCache bool `json:"from_cache,omitempty"` - SecurityViolation bool `json:"security_violation,omitempty"` - SourceEvidenceHash string `json:"source_evidence_hash,omitempty"` - Extra map[string]any `json:"extra,omitempty"` -} - -// Evidence is a single evidence record. -type Evidence struct { - SchemaVersion string `json:"schema_version,omitempty"` - ID string `json:"id"` - RunID string `json:"run_id,omitempty"` - AttemptID string `json:"attempt_id,omitempty"` - RequirementID string `json:"requirement_id,omitempty"` - ObligationID string `json:"obligation_id,omitempty"` - VerifierID string `json:"verifier_id,omitempty"` - Provider string `json:"provider,omitempty"` - ProviderVersion string `json:"provider_version,omitempty"` - Class string `json:"class"` // deterministic|probabilistic|human|informational - Confidence *float64 `json:"confidence,omitempty"` - Strength string `json:"strength,omitempty"` - Status string `json:"status,omitempty"` - Summary string `json:"summary"` - Paths []string `json:"paths,omitempty"` - Passed *bool `json:"passed,omitempty"` - Data map[string]any `json:"data,omitempty"` - RepositoryCommit string `json:"repository_commit,omitempty"` - BaseCommit string `json:"base_commit,omitempty"` - DiffHash string `json:"diff_hash,omitempty"` - RequirementHash string `json:"requirement_hash,omitempty"` - ObligationHash string `json:"obligation_hash,omitempty"` - PlanHash string `json:"verification_plan_hash,omitempty"` - StartedAt time.Time `json:"started_at,omitempty"` - CompletedAt time.Time `json:"completed_at,omitempty"` - SourceEvidenceHash string `json:"source_evidence_hash,omitempty"` - Artifacts []Artifact `json:"artifacts,omitempty"` -} - -// Artifact identifies a collected evidence artifact. -type Artifact struct { - Path string `json:"path"` - SHA256 string `json:"sha256"` - MediaType string `json:"media_type,omitempty"` -} - -// Provider converts tools into evidence. -type Provider interface { - Name() string - Version() string - Validate(spec ir.ProviderSpec) []Diagnostic - Execute(ctx context.Context, req Request) Result -} - -// Registry maps provider names to implementations. -type Registry struct { - byName map[string]Provider - lookPath func(string) (string, error) -} - -// NewRegistry returns a registry with built-in providers. -func NewRegistry(builtins ...Provider) *Registry { - r := &Registry{byName: map[string]Provider{}, lookPath: exec.LookPath} - for _, p := range builtins { - r.byName[p.Name()] = p - } - return r -} - -// Get returns a provider by name. -func (r *Registry) Get(name string) (Provider, bool) { - p, ok := r.byName[name] - if !ok && validProviderName(name) { - if path, err := r.lookPath("intentci-provider-" + name); err == nil { - return &ExternalProvider{ProviderName: name, Path: path}, true - } - } - return p, ok -} - -// DefaultRegistry returns all v1 built-in providers. -func DefaultRegistry() *Registry { - return NewRegistry( - &CommandProvider{}, - &BoundaryProvider{}, - &GitDiffProvider{}, - &JUnitProvider{}, - &SARIFProvider{}, - &JSONProvider{}, - &ManualProvider{}, - ) -} - -func boolPtr(b bool) *bool { return &b } - -func validProviderName(name string) bool { - if name == "" { - return false - } - for _, r := range name { - if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { - return false - } - } - return true -} - -func minimalEnvironment(req Request) []string { - values := environmentValues(req) - for name, value := range map[string]string{ - "INTENTCI_RUN_ID": req.RunID, - "INTENTCI_ATTEMPT_ID": req.AttemptID, - "INTENTCI_PROVIDER_ATTEMPT": fmt.Sprint(req.ExecutionAttempt), - "INTENTCI_REQUIREMENT_ID": req.RequirementID, - "INTENTCI_OBLIGATION_ID": req.ObligationID, - "INTENTCI_BASE_COMMIT": req.BaseCommit, - "INTENTCI_HEAD_COMMIT": req.HeadCommit, - "INTENTCI_EVIDENCE_DIR": req.EvidenceDir, - } { - values[name] = value - } - return sortedEnvironment(values) -} - -func environmentValues(req Request) map[string]string { - allowed := append([]string{"PATH", "TMPDIR", "TMP", "TEMP", "SYSTEMROOT", "COMSPEC"}, req.Spec.InheritEnv...) - values := map[string]string{} - for _, entry := range os.Environ() { - name, value, ok := strings.Cut(entry, "=") - if ok && matchesAny(allowed, name) { - values[name] = value - } - } - for name, value := range req.Spec.Environment { - values[name] = value - } - return values -} - -func sortedEnvironment(values map[string]string) []string { - names := make([]string, 0, len(values)) - for name := range values { - names = append(names, name) - } - sort.Strings(names) - env := make([]string, 0, len(names)) - for _, name := range names { - env = append(env, name+"="+values[name]) - } - return env -} - -// EnvironmentFingerprint hashes stable inherited and explicit environment, -// excluding run-specific variables injected by IntentCI. -func EnvironmentFingerprint(req Request) string { - raw, _ := json.Marshal(sortedEnvironment(environmentValues(req))) - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:]) -} - -func matchesAny(patterns []string, value string) bool { - for _, pattern := range patterns { - if ok, _ := path.Match(pattern, value); ok { - return true - } - } - return false -} diff --git a/internal/provider/provider_coverage_test.go b/internal/provider/provider_coverage_test.go deleted file mode 100644 index 3d5ea0a..0000000 --- a/internal/provider/provider_coverage_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package provider_test - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" -) - -func TestValidateAllProviders(t *testing.T) { - reg := provider.DefaultRegistry() - checks := []struct { - name string - spec ir.ProviderSpec - want int - }{ - {"command", ir.ProviderSpec{}, 1}, - {"command", ir.ProviderSpec{Run: "true"}, 0}, - {"boundary", ir.ProviderSpec{}, 1}, - {"boundary", ir.ProviderSpec{Allowed: []string{"a"}}, 0}, - {"git-diff", ir.ProviderSpec{}, 1}, - {"git-diff", ir.ProviderSpec{Paths: []string{"a"}}, 0}, - {"json", ir.ProviderSpec{}, 1}, - {"json", ir.ProviderSpec{Report: "x", Assert: map[string]any{"x": true}}, 0}, - {"junit", ir.ProviderSpec{}, 1}, - {"junit", ir.ProviderSpec{Report: "x"}, 0}, - {"sarif", ir.ProviderSpec{}, 1}, - {"sarif", ir.ProviderSpec{Run: "true"}, 1}, - {"manual", ir.ProviderSpec{}, 0}, - } - for _, c := range checks { - p, ok := reg.Get(c.name) - if !ok { - t.Fatalf("missing %s", c.name) - } - diags := p.Validate(c.spec) - if len(diags) != c.want { - t.Fatalf("%s validate=%v want %d", c.name, diags, c.want) - } - } -} - -func TestBoundaryAllowedAndDedupe(t *testing.T) { - p, _ := provider.DefaultRegistry().Get("boundary") - res := p.Execute(context.Background(), provider.Request{ - ChangedFiles: []string{"src/a.go", "src/a.go", "other.go"}, - Spec: ir.ProviderSpec{Allowed: []string{"src/**"}, Forbidden: []string{"migrations/**"}}, - }) - if res.Evidence[0].Passed == nil || *res.Evidence[0].Passed { - t.Fatalf("expected fail %+v", res) - } - res = p.Execute(context.Background(), provider.Request{ - ChangedFiles: []string{"src/a.go"}, - Spec: ir.ProviderSpec{Allowed: []string{"src/**"}}, - }) - if !*res.Evidence[0].Passed { - t.Fatal(res) - } - res = p.Execute(context.Background(), provider.Request{ - ChangedFiles: []string{"src/a.go"}, - Spec: ir.ProviderSpec{Forbidden: []string{"migrations/**"}}, - }) - if !*res.Evidence[0].Passed { - t.Fatalf("forbidden-only rule should allow unmatched paths: %+v", res) - } -} - -func TestCommandTimeoutExitAndExpect(t *testing.T) { - p := &provider.CommandProvider{} - res := p.Execute(context.Background(), provider.Request{ - Root: t.TempDir(), Timeout: 5 * time.Millisecond, - Spec: ir.ProviderSpec{ID: "t", Run: "sleep 2", Result: map[string]any{"equals": 0}}, - }) - if res.Status != "error" { - t.Fatalf("%+v", res) - } - res = p.Execute(context.Background(), provider.Request{ - Root: t.TempDir(), - Spec: ir.ProviderSpec{Run: "false", Result: map[string]any{"equals": float64(1)}}, - }) - if res.Evidence[0].Passed == nil || !*res.Evidence[0].Passed { - t.Fatalf("%+v", res) - } - res = p.Execute(context.Background(), provider.Request{ - Root: t.TempDir(), - Spec: ir.ProviderSpec{Run: "false", Result: map[string]any{"equals": 0}}, - }) - if *res.Evidence[0].Passed { - t.Fatal("expected fail") - } - // non-exit error via custom Exec - p.Exec = func(ctx context.Context, name string, arg ...string) *exec.Cmd { - return exec.CommandContext(ctx, "definitely-not-a-real-binary-xyz") - } - res = p.Execute(context.Background(), provider.Request{ - Root: t.TempDir(), Spec: ir.ProviderSpec{ID: "e", Run: "x"}, - }) - if res.Status != "error" { - t.Fatalf("%+v", res) - } -} - -func TestGitDiffExpectChanged(t *testing.T) { - p, _ := provider.DefaultRegistry().Get("git-diff") - res := p.Execute(context.Background(), provider.Request{ - ChangedFiles: []string{"a.go"}, - Spec: ir.ProviderSpec{Paths: []string{"a.go"}, Expect: map[string]any{"changed": true}}, - }) - if !*res.Evidence[0].Passed { - t.Fatal(res) - } - res = p.Execute(context.Background(), provider.Request{ - ChangedFiles: nil, - Spec: ir.ProviderSpec{Forbidden: []string{"a.go"}, Expect: map[string]any{"changed": true}}, - }) - if *res.Evidence[0].Passed { - t.Fatal("expected fail") - } - res = p.Execute(context.Background(), provider.Request{ - ChangedFiles: nil, - Spec: ir.ProviderSpec{Paths: []string{"a.go"}}, - }) - if !*res.Evidence[0].Passed { - t.Fatal(res) - } -} - -func TestJSONErrorsAndLookup(t *testing.T) { - p, _ := provider.DefaultRegistry().Get("json") - root := t.TempDir() - res := p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "missing.json", ID: "j"}, - }) - if res.Status != "error" { - t.Fatal(res) - } - path := filepath.Join(root, "bad.json") - if err := os.WriteFile(path, []byte("not-json"), 0o644); err != nil { - t.Fatal(err) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "bad.json"}, - }) - if res.Status != "error" { - t.Fatal(res) - } - if err := os.WriteFile(path, []byte(`[1,2]`), 0o644); err != nil { - t.Fatal(err) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "bad.json", Assert: map[string]any{"ok": true}}, - }) - if res.Evidence[0].Passed == nil || *res.Evidence[0].Passed { - t.Fatalf("lookup nil should fail assert %+v", res) - } - if err := os.WriteFile(path, []byte(`{"ok":false}`), 0o644); err != nil { - t.Fatal(err) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "bad.json", Assert: map[string]any{"ok": true}}, - }) - if *res.Evidence[0].Passed { - t.Fatal("assert fail") - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "bad.json"}, - }) - if !*res.Evidence[0].Passed { - t.Fatal(res) - } -} - -func TestJUnitBranches(t *testing.T) { - p := &provider.JUnitProvider{} - root := t.TempDir() - res := p.Execute(context.Background(), provider.Request{Root: root}) - if res.Status != "error" { - t.Fatalf("empty report should error: %+v", res) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Run: "true"}, - }) - if res.Status != "error" { - t.Fatalf("report required %+v", res) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "missing.xml"}, - }) - if res.Status != "error" { - t.Fatal(res) - } - path := filepath.Join(root, "bad.xml") - if err := os.WriteFile(path, []byte(" - -` - if err := os.WriteFile(path, []byte(suites), 0o644); err != nil { - t.Fatal(err) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{ID: "j", Report: "bad.xml"}, RetainStdout: true, - }) - if res.Evidence[0].Passed == nil || *res.Evidence[0].Passed { - t.Fatalf("%+v", res) - } - // single suite with cases when tests=0 - suite := ` - -` - if err := os.WriteFile(path, []byte(suite), 0o644); err != nil { - t.Fatal(err) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "bad.xml"}, - }) - if *res.Evidence[0].Passed { - t.Fatal(res) - } - // timeout path - p.Exec = func(ctx context.Context, name string, arg ...string) *exec.Cmd { - return exec.CommandContext(ctx, "sh", "-c", "sleep 2") - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Timeout: 5 * time.Millisecond, - Spec: ir.ProviderSpec{ID: "t", Run: "sleep", Report: "bad.xml"}, - }) - if res.Status != "error" { - t.Fatalf("%+v", res) - } - - // A fresh passing report cannot override a nonzero generator exit. - res = (&provider.JUnitProvider{}).Execute(context.Background(), provider.Request{ - Root: root, - Spec: ir.ProviderSpec{ - ID: "fresh", Report: "fresh.xml", - Run: `printf '' > fresh.xml; exit 1`, - }, - RetainStdout: true, - }) - if res.Status != "error" { - t.Fatalf("%+v", res) - } - - // A fresh failing report remains a failure even when the generator exits nonzero. - res = (&provider.JUnitProvider{}).Execute(context.Background(), provider.Request{ - Root: root, - Spec: ir.ProviderSpec{ - ID: "fresh-fail", Report: "fresh-fail.xml", - Run: `printf '' > fresh-fail.xml; exit 1`, - }, - }) - if res.Status != "completed" || *res.Evidence[0].Passed { - t.Fatalf("%+v", res) - } - - if err := os.Mkdir(filepath.Join(root, "report-dir"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "report-dir", "child"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - res = (&provider.JUnitProvider{}).Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Run: "true", Report: "report-dir"}, - }) - if res.Status != "error" { - t.Fatalf("%+v", res) - } -} - -func TestSARIFBranches(t *testing.T) { - p := &provider.SARIFProvider{} - root := t.TempDir() - res := p.Execute(context.Background(), provider.Request{Root: root}) - if res.Status != "error" { - t.Fatalf("empty report should error: %+v", res) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Run: "true"}, RetainStdout: true, - }) - if res.Status != "error" { - t.Fatal(res) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "missing.sarif"}, - }) - if res.Status != "error" { - t.Fatal(res) - } - path := filepath.Join(root, "bad.sarif") - if err := os.WriteFile(path, []byte("not-json"), 0o644); err != nil { - t.Fatal(err) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "bad.sarif"}, - }) - if res.Status != "error" { - t.Fatal(res) - } - if err := os.WriteFile(path, []byte(`{"version":"2.1.0","runs":[{"results":[{},{}]}]}`), 0o644); err != nil { - t.Fatal(err) - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{ID: "s", Report: "bad.sarif"}, RetainStdout: true, - }) - if *res.Evidence[0].Passed { - t.Fatal("expected findings fail") - } - - res = (&provider.SARIFProvider{}).Execute(context.Background(), provider.Request{ - Root: root, - Spec: ir.ProviderSpec{ - ID: "fresh", Report: "fresh.sarif", - Run: `printf '{"version":"2.1.0","runs":[{"results":[]}]}' > fresh.sarif; exit 1`, - }, - RetainStdout: true, - }) - if res.Status != "error" { - t.Fatalf("%+v", res) - } - - res = (&provider.SARIFProvider{}).Execute(context.Background(), provider.Request{ - Root: root, - Spec: ir.ProviderSpec{ - ID: "fresh-fail", Report: "fresh-fail.sarif", - Run: `printf '{"version":"2.1.0","runs":[{"results":[{}]}]}' > fresh-fail.sarif; exit 1`, - }, - }) - if res.Status != "completed" || *res.Evidence[0].Passed { - t.Fatalf("%+v", res) - } - - if err := os.Mkdir(filepath.Join(root, "sarif-dir"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "sarif-dir", "child"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - res = (&provider.SARIFProvider{}).Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Run: "true", Report: "sarif-dir"}, - }) - if res.Status != "error" { - t.Fatalf("%+v", res) - } - - p.Exec = func(ctx context.Context, name string, arg ...string) *exec.Cmd { - return exec.CommandContext(ctx, "sh", "-c", "sleep 2") - } - res = p.Execute(context.Background(), provider.Request{ - Root: root, Timeout: 5 * time.Millisecond, - Spec: ir.ProviderSpec{ID: "timeout", Run: "sleep", Report: "timeout.sarif"}, - }) - if res.Status != "error" { - t.Fatalf("%+v", res) - } -} diff --git a/internal/provider/provider_internal_test.go b/internal/provider/provider_internal_test.go deleted file mode 100644 index dc55e93..0000000 --- a/internal/provider/provider_internal_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package provider - -import "testing" - -func TestFirstNonEmptyEmpty(t *testing.T) { - if firstNonEmpty("", "") != "" { - t.Fatal("want empty") - } -} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go deleted file mode 100644 index 2ae3fa8..0000000 --- a/internal/provider/provider_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package provider_test - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" -) - -func TestCommandAndBoundary(t *testing.T) { - reg := provider.DefaultRegistry() - p, _ := reg.Get("command") - res := p.Execute(context.Background(), provider.Request{ - Root: t.TempDir(), Spec: ir.ProviderSpec{Provider: "command", ID: "c", Run: "true", Result: map[string]any{"equals": 0}}, - RetainStdout: true, RetainStderr: true, - }) - if res.Status != "completed" || res.Evidence[0].Passed == nil || !*res.Evidence[0].Passed { - t.Fatalf("%+v", res) - } - threshold := 0.8 - res = p.Execute(context.Background(), provider.Request{ - Root: t.TempDir(), - Spec: ir.ProviderSpec{ - Provider: "command", ID: "probabilistic", Run: "true", - EvidenceClass: "probabilistic", - }, - ConfidenceThreshold: &threshold, - }) - if res.Evidence[0].Confidence != nil { - t.Fatalf("command provider invented observed confidence: %+v", res) - } - b, _ := reg.Get("boundary") - res = b.Execute(context.Background(), provider.Request{ - ChangedFiles: []string{"migrations/1.sql"}, - Spec: ir.ProviderSpec{Provider: "boundary", Forbidden: []string{"migrations/**"}}, - }) - if res.Evidence[0].Passed == nil || *res.Evidence[0].Passed { - t.Fatalf("expected fail %+v", res) - } -} - -func TestJUnitSARIFJSONManual(t *testing.T) { - root := t.TempDir() - junitPath := filepath.Join(root, "out.xml") - if err := os.WriteFile(junitPath, []byte(``), 0o644); err != nil { - t.Fatal(err) - } - reg := provider.DefaultRegistry() - jp, _ := reg.Get("junit") - res := jp.Execute(context.Background(), provider.Request{Root: root, Spec: ir.ProviderSpec{Provider: "junit", Report: "out.xml"}}) - if res.Evidence[0].Passed == nil || !*res.Evidence[0].Passed { - t.Fatalf("%+v", res) - } - sarifPath := filepath.Join(root, "out.sarif") - if err := os.WriteFile(sarifPath, []byte(`{"version":"2.1.0","runs":[{"results":[]}]}`), 0o644); err != nil { - t.Fatal(err) - } - sp, _ := reg.Get("sarif") - res = sp.Execute(context.Background(), provider.Request{Root: root, Spec: ir.ProviderSpec{Provider: "sarif", Report: "out.sarif"}}) - if !*res.Evidence[0].Passed { - t.Fatal(res) - } - jsonPath := filepath.Join(root, "out.json") - if err := os.WriteFile(jsonPath, []byte(`{"ok":true}`), 0o644); err != nil { - t.Fatal(err) - } - jsp, _ := reg.Get("json") - res = jsp.Execute(context.Background(), provider.Request{Root: root, Spec: ir.ProviderSpec{Provider: "json", Report: "out.json", Assert: map[string]any{"ok": true}}}) - if !*res.Evidence[0].Passed { - t.Fatal(res) - } - mp, _ := reg.Get("manual") - res = mp.Execute(context.Background(), provider.Request{Spec: ir.ProviderSpec{Provider: "manual", ID: "m"}}) - if res.Evidence[0].Class != "human" { - t.Fatal(res) - } - gp, _ := reg.Get("git-diff") - res = gp.Execute(context.Background(), provider.Request{ - ChangedFiles: []string{"a.go"}, Spec: ir.ProviderSpec{Provider: "git-diff", Paths: []string{"a.go"}, Expect: map[string]any{"changed": false}}, - }) - if *res.Evidence[0].Passed { - t.Fatal("expected fail") - } -} - -func TestGeneratedReportsCannotReuseStalePass(t *testing.T) { - root := t.TempDir() - for _, tc := range []struct { - name string - provider string - report string - content string - }{ - {name: "junit", provider: "junit", report: "out.xml", content: ``}, - {name: "sarif", provider: "sarif", report: "out.sarif", content: `{"version":"2.1.0","runs":[{"results":[]}]}`}, - } { - t.Run(tc.name, func(t *testing.T) { - path := filepath.Join(root, tc.report) - if err := os.WriteFile(path, []byte(tc.content), 0o644); err != nil { - t.Fatal(err) - } - old := time.Now().Add(-time.Hour) - if err := os.Chtimes(path, old, old); err != nil { - t.Fatal(err) - } - p, _ := provider.DefaultRegistry().Get(tc.provider) - res := p.Execute(context.Background(), provider.Request{ - Root: root, - Spec: ir.ProviderSpec{Provider: tc.provider, Run: "false", Report: tc.report}, - }) - if res.Status != "error" { - t.Fatalf("stale passing report produced %q: %+v", res.Status, res) - } - }) - } -} diff --git a/internal/provider/provider_v1_internal_test.go b/internal/provider/provider_v1_internal_test.go deleted file mode 100644 index 00d9355..0000000 --- a/internal/provider/provider_v1_internal_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package provider - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/ir" -) - -func TestV1CommandAndExternalEdges(t *testing.T) { - command := (&CommandProvider{}).Execute(context.Background(), Request{ - Root: t.TempDir(), - Spec: ir.ProviderSpec{ - Run: `printf bad >&2`, - Result: map[string]any{ - "equals": 0, "stderr": map[string]any{"equals": "good"}, - }, - }, - }) - if *command.Evidence[0].Passed { - t.Fatal(command) - } - if ok, _ := matchOutput("stdout", "", "not-a-map"); ok { - t.Fatal("scalar output expectation accepted") - } - - external := (&ExternalProvider{ProviderName: "custom", Path: "/bin/true"}).Execute( - context.Background(), - Request{ - Root: t.TempDir(), Timeout: time.Second, - Spec: ir.ProviderSpec{ - WorkingDirectory: ".", - Configuration: map[string]any{"unsupported": make(chan int)}, - }, - }, - ) - if external.Status != "error" || !strings.Contains(external.Diagnostics[0], "unsupported") { - t.Fatal(external) - } - if validProviderName("") || validProviderName("Upper") || !validProviderName("lower-1") { - t.Fatal("provider name validation") - } -} - -func TestV1GitDiffExpectations(t *testing.T) { - changes := []Change{ - {Path: "renamed.go", Status: "renamed", Additions: 5, Deletions: 2}, - {Path: "deleted.go", Status: "deleted", Deletions: 4}, - {Path: "binary.bin", Status: "modified", Binary: true}, - } - if got := changeForPath(changes, "renamed.go"); got.Status != "renamed" { - t.Fatal(got) - } - if got := changeForPath(changes, "missing.go"); got.Status != "modified" { - t.Fatal(got) - } - for _, testCase := range []struct { - expect map[string]any - pass bool - }{ - {nil, true}, - {map[string]any{"status": "renamed"}, false}, - {map[string]any{"status": []string{"renamed", "deleted", "modified"}}, true}, - {map[string]any{"renamed": true, "deleted": true, "binary": true}, true}, - {map[string]any{"renamed": false}, false}, - {map[string]any{"max_additions": 4}, false}, - {map[string]any{"max_deletions": 5}, false}, - } { - pass, _ := evaluateChangeExpectations(testCase.expect, changes) - if pass != testCase.pass { - t.Fatalf("%v: %t", testCase.expect, pass) - } - } - result := (&GitDiffProvider{}).Execute(context.Background(), Request{ - ChangedFiles: []string{"renamed.go"}, Changes: changes, - Spec: ir.ProviderSpec{ - Paths: []string{"*.go"}, Expect: map[string]any{"changed": true, "renamed": false}, - }, - }) - if *result.Evidence[0].Passed || !strings.Contains(result.Evidence[0].Summary, "renamed") { - t.Fatal(result) - } -} - -func TestV1JSONHelperEdges(t *testing.T) { - if err := validateJSONAssert(nil); err != nil { - t.Fatal(err) - } - if pass, _, err := evaluateJSONAssert(map[string]any{"x": 1}, map[string]any{"x": 2}); err != nil || pass { - t.Fatalf("pass=%t err=%v", pass, err) - } - if pass, _, err := evaluateJSONAssert(map[string]any{}, map[string]any{"path": "$.missing", "equals": 1}); err != nil || pass { - t.Fatalf("pass=%t err=%v", pass, err) - } - for _, expression := range []string{"$[", "$[x]", "$x"} { - if _, _, err := jsonPath(nil, expression, false); err == nil { - t.Fatal(expression) - } - } - if _, exists, err := jsonPath(map[string]any{"x": "not-array"}, "$.x[0]", false); err != nil || exists { - t.Fatalf("exists=%t err=%v", exists, err) - } - if _, exists, err := jsonPath(map[string]any{"x": []any{}}, "$.x[0]", false); err != nil || exists { - t.Fatalf("exists=%t err=%v", exists, err) - } - if _, exists := lookupMember("not-map", "x"); exists { - t.Fatal("member found in scalar") - } - if _, err := compareJSON(1, 1, "unsupported"); err == nil { - t.Fatal("unsupported comparison") - } - for _, value := range []any{float32(1), int64(1)} { - if number, ok := number(value); !ok || number != 1 { - t.Fatalf("%T %v", value, value) - } - } - - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "bad.json"), []byte("{"), 0o644); err != nil { - t.Fatal(err) - } - result := (&JSONProvider{}).Execute(context.Background(), Request{ - Root: root, Spec: ir.ProviderSpec{Report: "bad.json", Assert: map[string]any{"x": 1}}, - }) - if result.Status != "error" { - t.Fatal(result) - } -} - -func TestV1JUnitExecutionEdges(t *testing.T) { - root := t.TempDir() - provider := &JUnitProvider{} - if result := provider.Execute(context.Background(), Request{ - Root: root, Spec: ir.ProviderSpec{Report: "../escape"}, - }); !result.SecurityViolation { - t.Fatal(result) - } - nonempty := filepath.Join(root, "nonempty") - if err := os.Mkdir(nonempty, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(nonempty, "x"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if result := provider.Execute(context.Background(), Request{ - Root: root, Spec: ir.ProviderSpec{Report: "nonempty", Run: "true"}, - }); result.Status != "error" || !strings.Contains(result.Diagnostics[0], "remove stale") { - t.Fatal(result) - } - timed := provider.Execute(context.Background(), Request{ - Root: root, Timeout: time.Millisecond, RetainStderr: true, - Spec: ir.ProviderSpec{Report: "timed.xml", Run: "sleep 1"}, - }) - if timed.Status != "error" || !strings.Contains(timed.Diagnostics[0], "timed out") { - t.Fatal(timed) - } - allSkipped := `` - if err := os.WriteFile(filepath.Join(root, "skipped.xml"), []byte(allSkipped), 0o644); err != nil { - t.Fatal(err) - } - skipped := provider.Execute(context.Background(), Request{ - Root: root, Spec: ir.ProviderSpec{Report: "skipped.xml"}, - }) - if skipped.Evidence[0].Passed != nil { - t.Fatal(skipped) - } - failures, total, err := parseJUnit([]byte(`xy`)) - if err != nil || failures != 2 || total != 1 { - t.Fatalf("%d/%d %v", failures, total, err) - } -} - -func TestV1SARIFExecutionAndMatchingEdges(t *testing.T) { - root := t.TempDir() - provider := &SARIFProvider{} - if result := provider.Execute(context.Background(), Request{ - Root: root, Spec: ir.ProviderSpec{Report: "../escape"}, - }); !result.SecurityViolation { - t.Fatal(result) - } - nonempty := filepath.Join(root, "nonempty") - if err := os.Mkdir(nonempty, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(nonempty, "x"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if result := provider.Execute(context.Background(), Request{ - Root: root, Spec: ir.ProviderSpec{Report: "nonempty", Run: "true"}, - }); result.Status != "error" || !strings.Contains(result.Diagnostics[0], "remove stale") { - t.Fatal(result) - } - timed := provider.Execute(context.Background(), Request{ - Root: root, Timeout: time.Millisecond, RetainStderr: true, - Spec: ir.ProviderSpec{Report: "timed.sarif", Run: "sleep 1"}, - }) - if timed.Status != "error" || !strings.Contains(timed.Diagnostics[0], "timed out") { - t.Fatal(timed) - } - if count, err := countSARIF([]byte(`{"runs":[{"results":[{},{}]}]}`)); err != nil || count != 2 { - t.Fatalf("%d %v", count, err) - } - if _, err := countSARIF([]byte("{")); err == nil { - t.Fatal("malformed SARIF counted") - } - if _, _, err := evaluateSARIF([]byte(`{"version":"2.1.0","runs":[]}`), - ir.ProviderSpec{Allow: map[string]any{"max_findings": "bad"}}); err == nil { - t.Fatal("invalid maximum accepted") - } - if _, _, err := evaluateSARIF([]byte(`{"runs":[]}`), ir.ProviderSpec{}); err == nil { - t.Fatal("missing SARIF version accepted") - } - filtered := `{"version":"2.1.0","runs":[{"results":[{"ruleId":"other"}]}]}` - if findings, _, err := evaluateSARIF([]byte(filtered), - ir.ProviderSpec{Match: map[string]any{"rule_id": "wanted"}}); err != nil || findings != 0 { - t.Fatalf("findings=%d err=%v", findings, err) - } - - base := sarifResult{RuleID: "R", Level: "error", BaselineState: "new", Properties: map[string]any{"security-severity": "9"}} - for _, match := range []map[string]any{ - {"rule_id": "other"}, {"result_level": "warning"}, {"baseline_state": "old"}, - {"severity": "1"}, {"path": "src/**"}, - } { - if matchesSARIF(base, match) { - t.Fatal(match) - } - } - if _, ok := integer(1.5); ok { - t.Fatal("fractional integer accepted") - } - if values := stringValues("bad"); values != nil { - t.Fatal(values) - } - if containsString([]string{"a"}, "b") { - t.Fatal("missing string found") - } -} diff --git a/internal/provider/providers_v1_test.go b/internal/provider/providers_v1_test.go deleted file mode 100644 index fcd6dd7..0000000 --- a/internal/provider/providers_v1_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package provider_test - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" -) - -func TestJSONPathSubsetAndValidation(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, "data.json") - if err := os.WriteFile(path, []byte(`{"items":[{"value":10}],"name":"demo"}`), 0o644); err != nil { - t.Fatal(err) - } - jsonProvider := &provider.JSONProvider{} - for _, assertion := range []map[string]any{ - {"path": "$.items[0].value", "equals": 10}, - {"path": "$.items[0].value", "not_equals": 11}, - {"path": "$.items[0].value", "gt": 9}, - {"path": "$.items[0].value", "gte": 10}, - {"path": "$.items[0].value", "lt": 11}, - {"path": "$.items[0].value", "lte": 10}, - {"path": "$.missing", "exists": false}, - {"name": "demo"}, - } { - spec := ir.ProviderSpec{ID: "json", Report: "data.json", Assert: assertion} - if diagnostics := jsonProvider.Validate(spec); len(diagnostics) != 0 { - t.Fatalf("%v: %v", assertion, diagnostics) - } - result := jsonProvider.Execute(context.Background(), provider.Request{Root: root, Spec: spec}) - if result.Status != "completed" || result.Evidence[0].Passed == nil || !*result.Evidence[0].Passed { - t.Fatalf("%v: %+v", assertion, result) - } - } - for _, assertion := range []map[string]any{ - {"path": "items", "equals": 1}, - {"path": "$..items", "equals": 1}, - {"path": "$.items[-1]", "equals": 1}, - {"path": "$.name", "gt": 1}, - {"path": "$.name", "exists": "yes"}, - {"path": "$.name", "equals": "demo", "not_equals": "x"}, - } { - spec := ir.ProviderSpec{Report: "data.json", Assert: assertion} - diagnostics := jsonProvider.Validate(spec) - result := jsonProvider.Execute(context.Background(), provider.Request{Root: root, Spec: spec}) - if len(diagnostics) == 0 && result.Status != "error" { - t.Fatalf("invalid assertion passed: %v %+v", assertion, result) - } - } - for _, spec := range []ir.ProviderSpec{ - {Report: "data.json"}, - {Report: "../outside.json", Assert: map[string]any{"x": true}}, - {Report: "missing.json", Assert: map[string]any{"x": true}}, - } { - result := jsonProvider.Execute(context.Background(), provider.Request{Root: root, Spec: spec}) - if result.Status != "error" && len(jsonProvider.Validate(spec)) == 0 { - t.Fatalf("%+v", result) - } - } -} - -func TestJUnitDetailsAndSARIFSubset(t *testing.T) { - root := t.TempDir() - junit := ` - -boom - -` - if err := os.WriteFile(filepath.Join(root, "junit.xml"), []byte(junit), 0o644); err != nil { - t.Fatal(err) - } - result := (&provider.JUnitProvider{}).Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "junit.xml"}, - }) - if result.Status != "completed" || *result.Evidence[0].Passed { - t.Fatalf("%+v", result) - } - data := result.Evidence[0].Data - if data["skipped"] != 1 || len(data["failure_messages"].([]string)) != 2 { - t.Fatalf("%v", data) - } - if err := os.WriteFile(filepath.Join(root, "empty.xml"), []byte(``), 0o644); err != nil { - t.Fatal(err) - } - empty := (&provider.JUnitProvider{}).Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "empty.xml"}, - }) - if empty.Evidence[0].Passed != nil { - t.Fatal(empty) - } - if err := os.WriteFile(filepath.Join(root, "wrong.xml"), []byte(``), 0o644); err != nil { - t.Fatal(err) - } - wrong := (&provider.JUnitProvider{}).Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{Report: "wrong.xml"}, - }) - if wrong.Status != "error" { - t.Fatal(wrong) - } - - sarif := `{ - "version":"2.1.0", - "runs":[{"results":[ - {"ruleId":"R1","level":"error","baselineState":"new","properties":{"security-severity":"9"},"locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/a.go"}}}]}, - {"ruleId":"R2","level":"warning","baselineState":"unchanged","locations":[]} - ]}] -}` - if err := os.WriteFile(filepath.Join(root, "result.sarif"), []byte(sarif), 0o644); err != nil { - t.Fatal(err) - } - sarifProvider := &provider.SARIFProvider{} - spec := ir.ProviderSpec{ - Report: "result.sarif", - Match: map[string]any{ - "rule_id": "R1", "result_level": "error", "baseline_state": "new", - "severity": "9", "path": "src/**", - }, - Allow: map[string]any{"max_findings": 1, "levels": []any{"error"}}, - } - if diagnostics := sarifProvider.Validate(spec); len(diagnostics) != 0 { - t.Fatal(diagnostics) - } - sarifResult := sarifProvider.Execute(context.Background(), provider.Request{Root: root, Spec: spec}) - if sarifResult.Status != "completed" || !*sarifResult.Evidence[0].Passed { - t.Fatalf("%+v", sarifResult) - } - for _, invalid := range []ir.ProviderSpec{ - {Report: "x", Match: map[string]any{"unknown": true}}, - {Report: "x", Allow: map[string]any{"unknown": true}}, - {Report: "x", Allow: map[string]any{"max_findings": -1}}, - {Report: "x", Allow: map[string]any{"levels": []any{}}}, - } { - if len(sarifProvider.Validate(invalid)) == 0 { - t.Fatalf("invalid SARIF spec passed: %+v", invalid) - } - } -} - -func TestCommandMatchersAndValidation(t *testing.T) { - command := &provider.CommandProvider{} - for _, spec := range []ir.ProviderSpec{ - {Run: "true", Result: map[string]any{"unknown": true}}, - {Run: "true", Result: map[string]any{"type": "signal"}}, - {Run: "true", Result: map[string]any{"equals": 1.5}}, - {Run: "true", Result: map[string]any{"equals": "zero"}}, - {Run: "true", Result: map[string]any{"stdout": "x"}}, - {Run: "true", Result: map[string]any{"stdout": map[string]any{"unknown": "x"}}}, - {Run: "true", Result: map[string]any{"stderr": map[string]any{"matches": "["}}}, - } { - if len(command.Validate(spec)) == 0 { - t.Fatalf("invalid command spec passed: %+v", spec) - } - } - valid := ir.ProviderSpec{ - Run: `printf hello; printf problem >&2`, - Result: map[string]any{ - "type": "exit_code", "equals": 0, - "stdout": map[string]any{"equals": "hello", "contains": "ell", "matches": "^h"}, - "stderr": map[string]any{"contains": "problem"}, - }, - } - if diagnostics := command.Validate(valid); len(diagnostics) != 0 { - t.Fatal(diagnostics) - } - result := command.Execute(context.Background(), provider.Request{ - Root: t.TempDir(), RetainStdout: true, RetainStderr: true, Spec: valid, - }) - if result.Status != "completed" || !*result.Evidence[0].Passed { - t.Fatal(result) - } - for _, matcher := range []map[string]any{ - {"equals": "different"}, {"contains": "missing"}, {"matches": "^z"}, - } { - spec := valid - spec.Result = map[string]any{"equals": 0, "stdout": matcher} - result := command.Execute(context.Background(), provider.Request{Root: t.TempDir(), Spec: spec}) - if *result.Evidence[0].Passed { - t.Fatalf("%v: %+v", matcher, result) - } - } -} diff --git a/internal/provider/sarif.go b/internal/provider/sarif.go deleted file mode 100644 index c70c0f9..0000000 --- a/internal/provider/sarif.go +++ /dev/null @@ -1,271 +0,0 @@ -package provider - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "time" - - "github.com/bmatcuk/doublestar/v4" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/security" -) - -// SARIFProvider runs a command and/or reads a SARIF report. -type SARIFProvider struct { - Exec func(ctx context.Context, name string, arg ...string) *exec.Cmd -} - -func (p *SARIFProvider) Name() string { return "sarif" } -func (p *SARIFProvider) Version() string { return "1.0.0" } - -func (p *SARIFProvider) Validate(spec ir.ProviderSpec) []Diagnostic { - if spec.Report == "" { - return []Diagnostic{{Message: "report required"}} - } - for key := range spec.Match { - if key != "rule_id" && key != "severity" && key != "path" && - key != "result_level" && key != "baseline_state" { - return []Diagnostic{{Message: fmt.Sprintf("unsupported SARIF match field %q", key)}} - } - } - for key, value := range spec.Allow { - switch key { - case "max_findings": - if parsed, ok := integer(value); !ok || parsed < 0 { - return []Diagnostic{{Message: "sarif allow.max_findings must be a non-negative integer"}} - } - case "levels": - if len(stringValues(value)) == 0 { - return []Diagnostic{{Message: "sarif allow.levels must be a non-empty string list"}} - } - default: - return []Diagnostic{{Message: fmt.Sprintf("unsupported SARIF allow field %q", key)}} - } - } - return nil -} - -func (p *SARIFProvider) Execute(ctx context.Context, req Request) Result { - start := time.Now() - res := Result{Provider: p.Name(), ProviderVersion: p.Version(), Status: "completed"} - report := req.Spec.Report - if report != "" { - var err error - report, err = security.ResolveInside(req.Root, report) - if err != nil { - res.Status = "error" - res.Diagnostics = []string{err.Error()} - res.SecurityViolation = security.IsPathViolation(err) - return res - } - } - var commandErr error - if req.Spec.Run != "" { - if report == "" { - res.Status = "error" - res.Diagnostics = []string{"report path required after run"} - res.DurationMS = time.Since(start).Milliseconds() - return res - } - if err := os.Remove(report); err != nil && !os.IsNotExist(err) { - res.Status = "error" - res.Diagnostics = []string{"remove stale sarif report: " + err.Error()} - res.DurationMS = time.Since(start).Milliseconds() - return res - } - process := runProcess(ctx, req, "sh", []string{"-c", req.Spec.Run}, nil, p.Exec) - commandErr = process.Err - res.SecurityViolation = process.SecurityViolation - res.ExitCode = process.ExitCode - if req.RetainStdout { - res.Stdout = process.Stdout - } - if req.RetainStderr { - res.Stderr = process.Stderr - } - if process.TimedOut { - res.Status = "error" - res.Diagnostics = []string{"sarif command timed out"} - res.DurationMS = time.Since(start).Milliseconds() - res.Evidence = []Evidence{{ID: req.Spec.ID, Class: "deterministic", Summary: "timed out", Passed: boolPtr(false)}} - return res - } - } - if report == "" { - res.Status = "error" - res.Diagnostics = []string{"report path required"} - res.DurationMS = time.Since(start).Milliseconds() - return res - } - data, err := os.ReadFile(report) - if err != nil { - res.Status = "error" - res.Diagnostics = []string{err.Error()} - res.DurationMS = time.Since(start).Milliseconds() - res.Evidence = []Evidence{{ID: req.Spec.ID, Class: "deterministic", Summary: err.Error(), Passed: boolPtr(false)}} - return res - } - findings, maximum, err := evaluateSARIF(data, req.Spec) - if err != nil { - res.Status = "error" - res.Diagnostics = []string{err.Error()} - res.DurationMS = time.Since(start).Milliseconds() - return res - } - passed := findings <= maximum - if passed && commandErr != nil { - res.Status = "error" - res.Diagnostics = []string{"sarif generator failed: " + commandErr.Error()} - res.DurationMS = time.Since(start).Milliseconds() - res.Evidence = []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "sarif"), Class: "deterministic", - Summary: "generator failed despite passing report", Passed: boolPtr(false), - }} - return res - } - res.Evidence = []Evidence{{ - ID: firstNonEmpty(req.Spec.ID, "sarif"), Class: firstNonEmpty(req.Spec.EvidenceClass, req.EvidenceClass, "deterministic"), - Summary: fmt.Sprintf("sarif: %d findings (max %d)", findings, maximum), Passed: boolPtr(passed), - Data: map[string]any{"findings": findings, "max_findings": maximum}, - }} - res.DurationMS = time.Since(start).Milliseconds() - return res -} - -func countSARIF(data []byte) (int, error) { - var doc struct { - Runs []struct { - Results []json.RawMessage `json:"results"` - } `json:"runs"` - } - if err := json.Unmarshal(data, &doc); err != nil { - return 0, fmt.Errorf("parse sarif: %w", err) - } - n := 0 - for _, r := range doc.Runs { - n += len(r.Results) - } - return n, nil -} - -type sarifDocument struct { - Version string `json:"version"` - Runs []struct { - Results []sarifResult `json:"results"` - } `json:"runs"` -} - -type sarifResult struct { - RuleID string `json:"ruleId"` - Level string `json:"level"` - BaselineState string `json:"baselineState"` - Properties map[string]any `json:"properties"` - Locations []struct { - PhysicalLocation struct { - ArtifactLocation struct { - URI string `json:"uri"` - } `json:"artifactLocation"` - } `json:"physicalLocation"` - } `json:"locations"` -} - -func evaluateSARIF(data []byte, spec ir.ProviderSpec) (int, int, error) { - var document sarifDocument - if err := json.Unmarshal(data, &document); err != nil { - return 0, 0, fmt.Errorf("parse sarif: %w", err) - } - if document.Version != "2.1.0" || document.Runs == nil { - return 0, 0, fmt.Errorf("parse sarif: version 2.1.0 and runs are required") - } - maximum := 0 - if raw, ok := spec.Allow["max_findings"]; ok { - if parsed, ok := integer(raw); ok { - maximum = parsed - } else { - return 0, 0, fmt.Errorf("sarif allow.max_findings must be an integer") - } - } - levels := stringValues(spec.Allow["levels"]) - findings := 0 - for _, run := range document.Runs { - for _, result := range run.Results { - if len(levels) > 0 && !containsString(levels, result.Level) { - continue - } - if !matchesSARIF(result, spec.Match) { - continue - } - findings++ - } - } - return findings, maximum, nil -} - -func matchesSARIF(result sarifResult, match map[string]any) bool { - if match == nil { - return true - } - if want, ok := match["rule_id"]; ok && result.RuleID != fmt.Sprint(want) { - return false - } - if want, ok := match["result_level"]; ok && result.Level != fmt.Sprint(want) { - return false - } - if want, ok := match["baseline_state"]; ok && result.BaselineState != fmt.Sprint(want) { - return false - } - if want, ok := match["severity"]; ok && fmt.Sprint(result.Properties["security-severity"]) != fmt.Sprint(want) { - return false - } - if want, ok := match["path"]; ok { - found := false - for _, location := range result.Locations { - if matched, _ := doublestar.Match(fmt.Sprint(want), location.PhysicalLocation.ArtifactLocation.URI); matched { - found = true - } - } - if !found { - return false - } - } - return true -} - -func integer(value any) (int, bool) { - switch typed := value.(type) { - case int: - return typed, true - case float64: - return int(typed), typed == float64(int(typed)) - default: - return 0, false - } -} - -func stringValues(value any) []string { - raw, ok := value.([]any) - if !ok { - if typed, ok := value.([]string); ok { - return typed - } - return nil - } - values := make([]string, 0, len(raw)) - for _, item := range raw { - values = append(values, fmt.Sprint(item)) - } - return values -} - -func containsString(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} diff --git a/internal/repair/repair.go b/internal/repair/repair.go deleted file mode 100644 index 2d83b19..0000000 --- a/internal/repair/repair.go +++ /dev/null @@ -1,447 +0,0 @@ -package repair - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/git" - "github.com/hypertrial/intentci/internal/security" - "github.com/hypertrial/intentci/internal/verdict" -) - -// Packet is the structured repair packet for agents. -type Packet struct { - RunID string `json:"run_id"` - Requirement string `json:"requirement_id,omitempty"` - Verdict string `json:"verdict"` - Summary string `json:"summary"` - Intent string `json:"intent,omitempty"` - AllowedPaths []string `json:"allowed_paths,omitempty"` - Forbidden []string `json:"forbidden_paths,omitempty"` - ProtectedPaths []string `json:"protected_paths,omitempty"` - TestChangesAllowed bool `json:"test_changes_allowed"` - Instructions []string `json:"instructions,omitempty"` - Failures []Failure `json:"failures"` - Attempt int `json:"attempt"` - MaxAttempts int `json:"max_attempts"` -} - -type Failure struct { - Requirement string `json:"requirement_id"` - Obligation string `json:"obligation_id"` - Verdict string `json:"verdict"` - Reason string `json:"reason"` - EvidenceIDs []string `json:"evidence_ids,omitempty"` - Paths []string `json:"paths,omitempty"` -} - -// Options configures the repair loop. -type Options struct { - Root string - Config *config.Config - Store *evidence.Store - AgentCommand string - MaxAttempts int - DryRun bool - Verify func(ctx context.Context) (*evidence.Bundle, error) - Finalize func(bundle *evidence.Bundle) error -} - -// Outcome is the repair loop result. -type Outcome struct { - Bundle *evidence.Bundle - Attempts int - ExitCode int - Stopped string -} - -// BuildPacket creates a repair packet from a failed bundle. -func BuildPacket(b *evidence.Bundle, attempt, max int, requirementID string) Packet { - p := Packet{ - RunID: b.RunID, Requirement: requirementID, Verdict: b.Run.Verdict, - Attempt: attempt, MaxAttempts: max, - Summary: "IntentCI verification did not pass; repair the failing obligations.", - Instructions: []string{ - "Change only files inside allowed paths and never files inside forbidden or protected paths.", - "Do not weaken, remove, or bypass required verification selectors.", - "Do not claim success; IntentCI will independently rerun verification.", - }, - } - for _, r := range b.Run.Requirements { - if requirementID != "" && r.ID != requirementID { - continue - } - for _, o := range r.Obligations { - if o.Verdict == verdict.Pass || o.Verdict == verdict.Skipped { - continue - } - failure := Failure{ - Requirement: r.ID, Obligation: o.ID, Verdict: o.Verdict, Reason: o.Reason, - } - for _, record := range o.Evidence { - failure.EvidenceIDs = append(failure.EvidenceIDs, record.ID) - failure.Paths = append(failure.Paths, record.Paths...) - } - failure.EvidenceIDs = uniqueSorted(failure.EvidenceIDs) - failure.Paths = uniqueSorted(failure.Paths) - p.Failures = append(p.Failures, failure) - } - } - if b.Document != nil { - for _, r := range b.Document.Requirements { - if requirementID != "" && r.ID != requirementID { - continue - } - if r.Intent != "" { - if p.Intent != "" { - p.Intent += "\n\n" - } - p.Intent += r.ID + ": " + r.Intent - } - p.AllowedPaths = append(p.AllowedPaths, r.Boundaries.Allowed...) - p.Forbidden = append(p.Forbidden, r.Boundaries.Forbidden...) - } - p.AllowedPaths = uniqueSorted(p.AllowedPaths) - p.Forbidden = uniqueSorted(p.Forbidden) - } - return p -} - -// Run executes the bounded repair loop. -func Run(ctx context.Context, opt Options) (outcome *Outcome, runErr error) { - defer func() { - if outcome == nil || outcome.Bundle == nil || opt.Finalize == nil { - return - } - if err := opt.Finalize(outcome.Bundle); err != nil && runErr == nil { - outcome.ExitCode = exitcode.Internal - runErr = err - } - }() - limit := opt.MaxAttempts - if limit <= 0 { - limit = opt.Config.Repair.MaxAttempts - } - limit = max(limit, 1) - - var last *evidence.Bundle - var diffFingerprints []string - var failFingerprints []string - agentErrors := 0 - initial, err := git.Resolve(opt.Root, "HEAD") - if err != nil { - return &Outcome{ExitCode: exitcode.Internal}, err - } - if violations := security.ProtectedViolation(initial.ChangedFiles, opt.Config.Repair.AllowRequirementChanges, opt.Config.Repair.ProtectedPaths); len(violations) > 0 { - return &Outcome{ExitCode: exitcode.SecurityBoundary, Stopped: "preexisting_protected_path:" + strings.Join(violations, ",")}, nil - } - - for attempt := 1; attempt <= limit; attempt++ { - b, err := opt.Verify(ctx) - if err != nil { - return &Outcome{ExitCode: exitcode.Internal, Attempts: attempt}, err - } - last = b - if ctx.Err() != nil || b.Interrupted { - b.Interrupted = true - b.Run.Verdict = verdict.Error - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.VerifierError, Stopped: "interrupted"}, nil - } - if b.Run.Verdict == verdict.Pass { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Pass}, nil - } - if b.Run.Verdict == verdict.ReviewRequired { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: verdict.ExitCode(verdict.ReviewRequired), Stopped: "review_required"}, nil - } - - packet := BuildPacket(b, attempt, limit, "") - packet.ProtectedPaths = uniqueSorted(append(append([]string{}, security.DefaultProtected...), opt.Config.Repair.ProtectedPaths...)) - packet.TestChangesAllowed = opt.Config.Repair.AllowTestChanges - attemptID := b.AttemptID - if attemptID == "" { - attemptID = fmt.Sprintf("attempt-%03d", attempt) - } - packetPath, err := opt.Store.WriteRepairPacketForAttempt(b.RunID, attemptID, packet) - if err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - - fp := failureFingerprint(packet) - if agentErrors == 0 && opt.Config.Repair.StopOnRepeatedFailure && contains(failFingerprints, fp) { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.RepairExhausted, Stopped: "repeated_failure"}, nil - } - failFingerprints = append(failFingerprints, fp) - - if attempt == limit { - break - } - if opt.DryRun || opt.AgentCommand == "" { - continue - } - - before, err := takeSnapshot(opt.Root) - if err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - ignoreStoreFiles(before, opt.Root, opt.Store.Root) - beforePatch, err := takePatch(opt.Root, opt.Store.Root) - if err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - if err := opt.Store.WriteRepairArtifact(b.RunID, attemptID, "patch-before.diff", beforePatch); err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - cmdStr := strings.ReplaceAll(opt.AgentCommand, "{packet}", packetPath) - cmdStr = strings.ReplaceAll(cmdStr, "{repository}", opt.Root) - cmdStr = strings.ReplaceAll(cmdStr, "{attempt}", fmt.Sprintf("%d", attempt)) - cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr) - cmd.Dir = opt.Root - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - agentErr := cmd.Run() - if err := opt.Store.WriteAgentLog(b.RunID, attemptID, "stdout", stdout.Bytes()); err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - if err := opt.Store.WriteAgentLog(b.RunID, attemptID, "stderr", stderr.Bytes()); err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - exitRecord := map[string]any{"status": "completed", "exit_code": 0} - if agentErr != nil { - exitRecord["status"] = "error" - exitRecord["error"] = agentErr.Error() - exitRecord["exit_code"] = agentExitCode(agentErr) - agentErrors++ - } else { - agentErrors = 0 - } - exitRaw, _ := json.MarshalIndent(exitRecord, "", " ") - if err := opt.Store.WriteRepairArtifact(b.RunID, attemptID, "agent-exit.json", append(exitRaw, '\n')); err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - if ctx.Err() != nil { - b.Interrupted = true - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.VerifierError, Stopped: "interrupted"}, nil - } - - after, err := takeSnapshot(opt.Root) - if err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - ignoreStoreFiles(after, opt.Root, opt.Store.Root) - afterPatch, err := takePatch(opt.Root, opt.Store.Root) - if err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - if err := opt.Store.WriteRepairArtifact(b.RunID, attemptID, "patch-after.diff", afterPatch); err != nil { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.Internal}, err - } - changed := diffPaths(before, after) - if viol := security.ProtectedViolation(changed, opt.Config.Repair.AllowRequirementChanges, opt.Config.Repair.ProtectedPaths); len(viol) > 0 { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.SecurityBoundary, Stopped: "protected_path:" + strings.Join(viol, ",")}, nil - } - if viol := security.BoundaryViolations(changed, packet.AllowedPaths, packet.Forbidden); len(viol) > 0 { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.SecurityBoundary, Stopped: "boundary:" + strings.Join(viol, ",")}, nil - } - if !opt.Config.Repair.AllowTestChanges { - for _, c := range changed { - if security.IsTestPath(c) { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.SecurityBoundary, Stopped: "test_change:" + c}, nil - } - } - } - dfp := diffFingerprint(before, after) - if opt.Config.Repair.StopOnRepeatedDiff && contains(diffFingerprints, dfp) { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.RepairExhausted, Stopped: "repeated_diff"}, nil - } - diffFingerprints = append(diffFingerprints, dfp) - if agentErrors >= 2 { - return &Outcome{Bundle: b, Attempts: attempt, ExitCode: exitcode.RepairExhausted, Stopped: "repeated_agent_error"}, nil - } - } - - return &Outcome{Bundle: last, Attempts: limit, ExitCode: exitcode.RepairExhausted, Stopped: "max_attempts"}, nil -} - -func agentExitCode(err error) int { - var exitError *exec.ExitError - if errors.As(err, &exitError) { - return exitError.ExitCode() - } - return -1 -} - -func capturePatch(root, storeRoot string) ([]byte, error) { - arguments := []string{"diff", "--binary", "--no-ext-diff", "HEAD", "--", "."} - excluded := storePrefix(root, storeRoot) - if excluded != "" { - arguments = append(arguments, ":(exclude)"+excluded+"/**") - } - tracked, err := gitOutput(root, arguments...) - if err != nil { - return nil, err - } - raw, err := gitOutput(root, "ls-files", "--others", "--exclude-standard") - if err != nil { - return nil, err - } - var output bytes.Buffer - output.Write(tracked) - for _, relative := range strings.Split(strings.TrimSpace(string(raw)), "\n") { - if relative == "" { - continue - } - if excluded != "" && (relative == excluded || strings.HasPrefix(filepath.ToSlash(relative), excluded+"/")) { - continue - } - content, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(relative))) - if err != nil { - return nil, err - } - fmt.Fprintf(&output, "\nintentci-untracked %s sha256=%s\n", filepath.ToSlash(relative), hashBytes(content)) - output.Write(content) - if len(content) > 0 && content[len(content)-1] != '\n' { - output.WriteByte('\n') - } - } - return output.Bytes(), nil -} - -var takePatch = capturePatch - -var gitOutput = func(root string, arguments ...string) ([]byte, error) { - command := exec.Command("git", arguments...) - command.Dir = root - return command.Output() -} - -var relativePath = filepath.Rel - -func storePrefix(root, storeRoot string) string { - relative, err := relativePath(root, storeRoot) - if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return "" - } - return filepath.ToSlash(relative) -} - -func hashBytes(content []byte) string { - sum := sha256.Sum256(content) - return hex.EncodeToString(sum[:]) -} - -func failureFingerprint(p Packet) string { - b, _ := json.Marshal(p.Failures) - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} - -func hashStrings(ss []string) string { - b, _ := json.Marshal(ss) - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} - -func uniqueSorted(ss []string) []string { - sort.Strings(ss) - out := ss[:0] - for _, s := range ss { - if len(out) == 0 || out[len(out)-1] != s { - out = append(out, s) - } - } - return out -} - -func contains(ss []string, s string) bool { - for _, x := range ss { - if x == s { - return true - } - } - return false -} - -func snapshotDiff(root string) (map[string]string, error) { - cmd := exec.Command("git", "ls-files", "--cached", "--others", "--exclude-standard") - cmd.Dir = root - raw, err := cmd.Output() - if err != nil { - return nil, err - } - out := map[string]string{} - for _, f := range strings.Split(strings.TrimSpace(string(raw)), "\n") { - if f == "" { - continue - } - path := filepath.Join(root, filepath.FromSlash(f)) - info, err := os.Lstat(path) - if err != nil { - out[filepath.ToSlash(f)] = "missing:" + err.Error() - continue - } - var data []byte - if info.Mode()&os.ModeSymlink != 0 { - target, _ := os.Readlink(path) - data = []byte("symlink:" + target) - } else { - data, _ = os.ReadFile(path) - } - sum := sha256.Sum256(append([]byte(info.Mode().String()+"\x00"), data...)) - out[filepath.ToSlash(f)] = hex.EncodeToString(sum[:]) - } - return out, nil -} - -var takeSnapshot = snapshotDiff - -func diffPaths(before, after map[string]string) []string { - var out []string - for k, v := range after { - if before[k] != v { - out = append(out, k) - } - } - for k := range before { - if after[k] == "" { - out = append(out, k) - } - } - sort.Strings(out) - return out -} - -func diffFingerprint(before, after map[string]string) string { - changed := diffPaths(before, after) - parts := make([]string, 0, len(changed)) - for _, path := range changed { - parts = append(parts, path+"\x00"+before[path]+"\x00"+after[path]) - } - return hashStrings(parts) -} - -func ignoreStoreFiles(snapshot map[string]string, repoRoot, storeRoot string) { - rel, err := filepath.Rel(repoRoot, storeRoot) - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return - } - prefix := filepath.ToSlash(rel) + "/" - for path := range snapshot { - if strings.HasPrefix(path, prefix) { - delete(snapshot, path) - } - } -} diff --git a/internal/repair/repair_coverage_test.go b/internal/repair/repair_coverage_test.go deleted file mode 100644 index 1bc90d8..0000000 --- a/internal/repair/repair_coverage_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package repair_test - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/repair" - "github.com/hypertrial/intentci/internal/verdict" -) - -func gitInit(t *testing.T, dir string) { - t.Helper() - for _, c := range [][]string{ - {"git", "init"}, - {"git", "config", "user.email", "t@e.com"}, - {"git", "config", "user.name", "t"}, - } { - cmd := exec.Command(c[0], c[1:]...) - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } -} - -func gitCommitTree(t *testing.T, dir string, files map[string]string) { - t.Helper() - for path, body := range files { - full := filepath.Join(dir, path) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - } - for _, c := range [][]string{ - {"git", "add", "."}, - {"git", "commit", "-m", "c"}, - } { - cmd := exec.Command(c[0], c[1:]...) - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } -} - -func failBundle(id string) *evidence.Bundle { - return &evidence.Bundle{ - RunID: id, - Run: verdict.RunResult{ - Verdict: verdict.Fail, - Requirements: []verdict.RequirementResult{ - {ID: "REQ-1", Obligations: []verdict.ObligationResult{ - {ID: "O1", Verdict: verdict.Fail, Reason: "x"}, - {ID: "O2", Verdict: verdict.Pass}, - {ID: "O3", Verdict: verdict.Skipped}, - }}, - {ID: "REQ-2", Obligations: []verdict.ObligationResult{{ID: "Z", Verdict: verdict.Fail}}}, - }, - }, - } -} - -func TestBuildPacketFilter(t *testing.T) { - b := failBundle("r") - p := repair.BuildPacket(b, 1, 2, "REQ-1") - if len(p.Failures) != 1 || p.Failures[0].Obligation != "O1" { - t.Fatalf("%+v", p) - } -} - -func TestRepairVerifyErrorAndRepeatedFailure(t *testing.T) { - root := t.TempDir() - gitInit(t, root) - gitCommitTree(t, root, map[string]string{"README.md": "x\n"}) - store, err := evidence.NewStore(root, t.TempDir()) - if err != nil { - t.Fatal(err) - } - cfg := config.Default() - cfg.Repair.MaxAttempts = 3 - cfg.Repair.StopOnRepeatedFailure = true - _, err = repair.Run(context.Background(), repair.Options{ - Root: root, Config: cfg, Store: store, DryRun: true, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { - return nil, context.Canceled - }, - }) - if err == nil { - t.Fatal("expected error") - } - - b := failBundle("r1") - out, err := repair.Run(context.Background(), repair.Options{ - Root: root, Config: cfg, Store: store, DryRun: true, MaxAttempts: 3, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { return b, nil }, - }) - if err != nil { - t.Fatal(err) - } - if out.Stopped != "repeated_failure" || out.ExitCode != exitcode.RepairExhausted { - t.Fatalf("%+v", out) - } -} - -func TestRepairAgentProtectedTestAndRepeatedDiff(t *testing.T) { - root := t.TempDir() - gitInit(t, root) - gitCommitTree(t, root, map[string]string{ - ".intentci/config.yaml": "version: 1\n", - ".intentci/requirements/REQ-001.md": "x\n", - "internal/foo_test.go": "package x\n", - "touch.txt": "old\n", - }) - store, err := evidence.NewStore(root, ".intentci/runs") - if err != nil { - t.Fatal(err) - } - cfg := config.Default() - cfg.Repair.MaxAttempts = 3 - cfg.Repair.StopOnRepeatedDiff = true - cfg.Repair.StopOnRepeatedFailure = false - cfg.Repair.AllowTestChanges = false - cfg.Repair.AllowRequirementChanges = false - - agent := `echo changed > .intentci/requirements/REQ-001.md` - b := failBundle("rp") - out, err := repair.Run(context.Background(), repair.Options{ - Root: root, Config: cfg, Store: store, AgentCommand: agent + " # {packet}", MaxAttempts: 2, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { return b, nil }, - }) - if err != nil { - t.Fatal(err) - } - if out.ExitCode != exitcode.SecurityBoundary || out.Stopped == "" { - t.Fatalf("%+v", out) - } - - root2 := t.TempDir() - gitInit(t, root2) - gitCommitTree(t, root2, map[string]string{"internal/foo_test.go": "package x\n"}) - store2, _ := evidence.NewStore(root2, ".intentci/runs") - agent2 := `echo x >> internal/foo_test.go` - b2 := failBundle("rt") - out, err = repair.Run(context.Background(), repair.Options{ - Root: root2, Config: cfg, Store: store2, AgentCommand: agent2, MaxAttempts: 2, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { return b2, nil }, - }) - if err != nil { - t.Fatal(err) - } - if out.ExitCode != exitcode.SecurityBoundary { - t.Fatalf("%+v", out) - } - - root3 := t.TempDir() - gitInit(t, root3) - gitCommitTree(t, root3, map[string]string{"touch.txt": "old\n"}) - store3, _ := evidence.NewStore(root3, ".intentci/runs") - cfg3 := config.Default() - cfg3.Repair.StopOnRepeatedDiff = true - cfg3.Repair.StopOnRepeatedFailure = false - cfg3.Repair.AllowTestChanges = true - agent3 := `echo same > touch.txt` - n := 0 - out, err = repair.Run(context.Background(), repair.Options{ - Root: root3, Config: cfg3, Store: store3, AgentCommand: agent3, MaxAttempts: 4, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { - n++ - bb := failBundle("rd") - bb.RunID = "rd" - bb.Run.Requirements[0].Obligations[0].Reason = filepath.Join("x", string(rune('a'+n))) - return bb, nil - }, - }) - if err != nil { - t.Fatal(err) - } - if out.Stopped != "repeated_diff" { - t.Fatalf("%+v", out) - } - - root4 := t.TempDir() - gitInit(t, root4) - gitCommitTree(t, root4, map[string]string{"README.md": "x\n"}) - store4, _ := evidence.NewStore(root4, t.TempDir()) - _ = os.WriteFile(filepath.Join(store4.Root, "rw"), []byte("x"), 0o644) - bb := failBundle("rw") - out, err = repair.Run(context.Background(), repair.Options{ - Root: root4, Config: config.Default(), Store: store4, DryRun: true, MaxAttempts: 1, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { return bb, nil }, - }) - if err == nil && out.ExitCode != exitcode.Internal { - t.Fatalf("expected packet write failure %+v err=%v", out, err) - } -} - -func TestRepairMaxAttemptsDefault(t *testing.T) { - root := t.TempDir() - gitInit(t, root) - gitCommitTree(t, root, map[string]string{"README.md": "x\n"}) - store, _ := evidence.NewStore(root, t.TempDir()) - cfg := config.Default() - cfg.Repair.MaxAttempts = 3 - cfg.Repair.StopOnRepeatedFailure = false - attempts := 0 - out, err := repair.Run(context.Background(), repair.Options{ - Root: root, Config: cfg, Store: store, DryRun: true, AgentCommand: "", - Verify: func(ctx context.Context) (*evidence.Bundle, error) { - attempts++ - return failBundle("m"), nil - }, - }) - if err != nil { - t.Fatal(err) - } - if out.Stopped != "max_attempts" || attempts != 3 { - t.Fatalf("%+v attempts=%d", out, attempts) - } - - root = t.TempDir() - gitInit(t, root) - gitCommitTree(t, root, map[string]string{"README.md": "x\n"}) - store, _ = evidence.NewStore(root, t.TempDir()) - cfg.Repair.MaxAttempts = 0 - attempts = 0 - out, err = repair.Run(context.Background(), repair.Options{ - Root: root, Config: cfg, Store: store, DryRun: true, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { - attempts++ - return failBundle("clamped"), nil - }, - }) - if err != nil || out.Stopped != "max_attempts" || attempts != 1 { - t.Fatalf("%+v attempts=%d err=%v", out, attempts, err) - } -} diff --git a/internal/repair/repair_internal_test.go b/internal/repair/repair_internal_test.go deleted file mode 100644 index 591de48..0000000 --- a/internal/repair/repair_internal_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package repair - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestHelpers(t *testing.T) { - if hashStrings([]string{"a", "b"}) == "" { - t.Fatal("hash") - } - before := map[string]string{"a": "1", "b": "1"} - after := map[string]string{"b": "1", "c": "1"} - got := diffPaths(before, after) - if len(got) != 2 || got[0] != "a" || got[1] != "c" { - t.Fatalf("%v", got) - } - // snapshotDiff error path (not a git repo) - m, err := snapshotDiff(t.TempDir()) - if err == nil || len(m) != 0 { - t.Fatalf("err=%v m=%v", err, m) - } - if contains([]string{"x"}, "y") || !contains([]string{"x"}, "x") { - t.Fatal("contains") - } - if diffFingerprint(before, after) == "" { - t.Fatal("diff fingerprint") - } - if got := uniqueSorted([]string{"b", "a", "a"}); len(got) != 2 || got[0] != "a" { - t.Fatal(got) - } - snapshot := map[string]string{"runs/a": "1", "keep": "2"} - ignoreStoreFiles(snapshot, "/repo", "/repo/runs") - if _, ok := snapshot["runs/a"]; ok { - t.Fatal(snapshot) - } - ignoreStoreFiles(snapshot, "/repo", "/outside") - - if got := storePrefix("/repo", "/repo/runs"); got != "runs" { - t.Fatalf("child store prefix = %q", got) - } - for name, storeRoot := range map[string]string{ - "same": "/repo", - "parent": "/", - "sibling": "/other", - } { - t.Run("store-prefix-"+name, func(t *testing.T) { - if got := storePrefix("/repo", storeRoot); got != "" { - t.Fatalf("store prefix = %q", got) - } - }) - } - oldRelativePath := relativePath - relativePath = func(string, string) (string, error) { return "", errors.New("relative") } - if got := storePrefix("/repo", "/repo/runs"); got != "" { - t.Fatalf("errored store prefix = %q", got) - } - relativePath = oldRelativePath - - root := t.TempDir() - cmd := exec.Command("git", "init") - cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - empty, err := snapshotDiff(root) - if err != nil || len(empty) != 0 { - t.Fatalf("%v %v", empty, err) - } - target := filepath.Join(root, "target") - link := filepath.Join(root, "link") - if err := os.WriteFile(target, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Symlink("target", link); err != nil { - t.Fatal(err) - } - add := exec.Command("git", "add", "target", "link") - add.Dir = root - if out, err := add.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - files, err := snapshotDiff(root) - if err != nil || len(files) != 2 { - t.Fatalf("%v %v", files, err) - } - if err := os.Remove(target); err != nil { - t.Fatal(err) - } - files, err = snapshotDiff(root) - if err != nil || files["target"] == "" { - t.Fatalf("%v %v", files, err) - } -} - -func TestRunSnapshotErrors(t *testing.T) { - nonGit := t.TempDir() - store, _ := evidence.NewStore(nonGit, t.TempDir()) - out, err := Run(context.Background(), Options{ - Root: nonGit, Config: config.Default(), Store: store, - Verify: func(context.Context) (*evidence.Bundle, error) { return nil, nil }, - }) - if err == nil || out.ExitCode != exitcode.Internal { - t.Fatalf("%+v %v", out, err) - } - - root := t.TempDir() - for _, args := range [][]string{ - {"init"}, - {"config", "user.email", "test@example.com"}, - {"config", "user.name", "Test"}, - } { - cmd := exec.Command("git", args...) - cmd.Dir = root - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, output) - } - } - if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - for _, args := range [][]string{{"add", "."}, {"commit", "-m", "initial"}} { - cmd := exec.Command("git", args...) - cmd.Dir = root - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, output) - } - } - store, _ = evidence.NewStore(root, t.TempDir()) - failed := &evidence.Bundle{RunID: "r", Run: verdict.RunResult{Verdict: verdict.Fail}} - old := takeSnapshot - defer func() { takeSnapshot = old }() - takeSnapshot = func(string) (map[string]string, error) { return nil, errors.New("snapshot") } - out, err = Run(context.Background(), Options{ - Root: root, Config: config.Default(), Store: store, MaxAttempts: 2, AgentCommand: "true", - Verify: func(context.Context) (*evidence.Bundle, error) { return failed, nil }, - }) - if err == nil || out.ExitCode != exitcode.Internal { - t.Fatalf("%+v %v", out, err) - } - - calls := 0 - takeSnapshot = func(string) (map[string]string, error) { - calls++ - if calls == 2 { - return nil, errors.New("snapshot") - } - return map[string]string{"README.md": "x"}, nil - } - out, err = Run(context.Background(), Options{ - Root: root, Config: config.Default(), Store: store, MaxAttempts: 2, AgentCommand: "true", - Verify: func(context.Context) (*evidence.Bundle, error) { return failed, nil }, - }) - if err == nil || out.ExitCode != exitcode.Internal { - t.Fatalf("%+v %v", out, err) - } -} diff --git a/internal/repair/repair_test.go b/internal/repair/repair_test.go deleted file mode 100644 index bf8a9ca..0000000 --- a/internal/repair/repair_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package repair_test - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/repair" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestBuildPacketAndDryRun(t *testing.T) { - b := &evidence.Bundle{ - RunID: "r1", - Run: verdict.RunResult{ - Verdict: verdict.Fail, - Requirements: []verdict.RequirementResult{{ - ID: "REQ-1", - Obligations: []verdict.ObligationResult{{ID: "O1", Verdict: verdict.Fail, Reason: "x"}}, - }}, - }, - } - p := repair.BuildPacket(b, 1, 3, "") - if len(p.Failures) != 1 { - t.Fatalf("%+v", p) - } - root := gitRepo(t) - store, err := evidence.NewStore(root, t.TempDir()) - if err != nil { - t.Fatal(err) - } - cfg := config.Default() - cfg.Repair.MaxAttempts = 2 - attempts := 0 - out, err := repair.Run(context.Background(), repair.Options{ - Root: root, Config: cfg, Store: store, DryRun: true, MaxAttempts: 2, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { - attempts++ - return b, nil - }, - }) - if err != nil { - t.Fatal(err) - } - if out.ExitCode != exitcode.RepairExhausted || attempts != 2 { - t.Fatalf("%+v attempts=%d", out, attempts) - } - if _, err := os.Stat(filepath.Join(store.Dir("r1"), "repair-packet.json")); err != nil { - // packet written on first attempt with run id r1 - _ = err - } -} - -func TestPassShortCircuit(t *testing.T) { - root := gitRepo(t) - store, _ := evidence.NewStore(root, t.TempDir()) - cfg := config.Default() - out, err := repair.Run(context.Background(), repair.Options{ - Root: root, Config: cfg, Store: store, DryRun: true, - Verify: func(ctx context.Context) (*evidence.Bundle, error) { - return &evidence.Bundle{RunID: "ok", Run: verdict.RunResult{Verdict: verdict.Pass}}, nil - }, - }) - if err != nil || out.ExitCode != exitcode.Pass { - t.Fatalf("%v %+v", err, out) - } -} - -func TestPacketIncludesRequirementBoundaries(t *testing.T) { - b := &evidence.Bundle{ - RunID: "r", - Document: &ir.Document{Requirements: []ir.Requirement{{ - ID: "REQ-1", - Boundaries: ir.Boundaries{ - Allowed: []string{"src/**"}, - Forbidden: []string{"secrets/**"}, - }, - }, {ID: "REQ-2", Boundaries: ir.Boundaries{Allowed: []string{"other/**"}}}}}, - Run: verdict.RunResult{ - Verdict: verdict.Fail, - Requirements: []verdict.RequirementResult{{ - ID: "REQ-1", - Obligations: []verdict.ObligationResult{{ID: "O1", Verdict: verdict.Fail}}, - }}, - }, - } - p := repair.BuildPacket(b, 1, 2, "REQ-1") - if len(p.AllowedPaths) != 1 || p.AllowedPaths[0] != "src/**" || len(p.Forbidden) != 1 { - t.Fatalf("%+v", p) - } -} - -func TestRepairRejectsPreexistingProtectedChanges(t *testing.T) { - root := gitRepo(t) - path := filepath.Join(root, ".intentci", "requirements", "REQ-001.md") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("contract\n"), 0o644); err != nil { - t.Fatal(err) - } - gitRun(t, root, "add", ".") - gitRun(t, root, "commit", "-m", "contract") - if err := os.WriteFile(path, []byte("modified\n"), 0o644); err != nil { - t.Fatal(err) - } - store, _ := evidence.NewStore(root, t.TempDir()) - called := false - out, err := repair.Run(context.Background(), repair.Options{ - Root: root, Config: config.Default(), Store: store, MaxAttempts: 2, - Verify: func(context.Context) (*evidence.Bundle, error) { - called = true - return nil, nil - }, - }) - if err != nil || out.ExitCode != exitcode.SecurityBoundary || called { - t.Fatalf("err=%v out=%+v called=%v", err, out, called) - } -} - -func TestRepairRejectsChangesOutsideAllowedBoundary(t *testing.T) { - root := gitRepo(t) - store, _ := evidence.NewStore(root, t.TempDir()) - failed := &evidence.Bundle{ - RunID: "run", - Document: &ir.Document{Requirements: []ir.Requirement{{ - ID: "REQ-1", Boundaries: ir.Boundaries{Allowed: []string{"src/**"}}, - }}}, - Run: verdict.RunResult{ - Verdict: verdict.Fail, - Requirements: []verdict.RequirementResult{{ - ID: "REQ-1", - Obligations: []verdict.ObligationResult{{ID: "O1", Verdict: verdict.Fail}}, - }}, - }, - } - out, err := repair.Run(context.Background(), repair.Options{ - Root: root, Config: config.Default(), Store: store, MaxAttempts: 2, - AgentCommand: "printf changed >> README.md", - Verify: func(context.Context) (*evidence.Bundle, error) { - return failed, nil - }, - }) - if err != nil || out.ExitCode != exitcode.SecurityBoundary { - t.Fatalf("err=%v out=%+v", err, out) - } -} - -func gitRepo(t *testing.T) string { - t.Helper() - root := t.TempDir() - gitRun(t, root, "init") - gitRun(t, root, "config", "user.email", "test@example.com") - gitRun(t, root, "config", "user.name", "Test") - if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("test\n"), 0o644); err != nil { - t.Fatal(err) - } - gitRun(t, root, "add", ".") - gitRun(t, root, "commit", "-m", "initial") - return root -} - -func gitRun(t *testing.T, root string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v: %s", args, err, out) - } -} diff --git a/internal/repair/repair_v1_internal_test.go b/internal/repair/repair_v1_internal_test.go deleted file mode 100644 index 1f5200c..0000000 --- a/internal/repair/repair_v1_internal_test.go +++ /dev/null @@ -1,355 +0,0 @@ -package repair - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -func repairRepo(t *testing.T) string { - t.Helper() - root := t.TempDir() - for _, arguments := range [][]string{ - {"init"}, {"config", "user.email", "test@example.com"}, {"config", "user.name", "Test"}, - } { - command := exec.Command("git", arguments...) - command.Dir = root - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v: %s", arguments, err, output) - } - } - if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("base\n"), 0o644); err != nil { - t.Fatal(err) - } - for _, arguments := range [][]string{{"add", "."}, {"commit", "-m", "base"}} { - command := exec.Command("git", arguments...) - command.Dir = root - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v: %s", arguments, err, output) - } - } - return root -} - -func failedRepairBundle(runID, attemptID string) *evidence.Bundle { - return &evidence.Bundle{ - RunID: runID, AttemptID: attemptID, - Run: verdict.RunResult{ - Verdict: verdict.Fail, - Requirements: []verdict.RequirementResult{{ - ID: "REQ", Verdict: verdict.Fail, - Obligations: []verdict.ObligationResult{{ - ID: "OBL", Verdict: verdict.Fail, Reason: "failed", - }}, - }}, - }, - } -} - -func TestBuildPacketEvidenceIntentAndFiltering(t *testing.T) { - passed, failed := true, false - bundle := failedRepairBundle("run", "attempt") - bundle.Run.Requirements = append(bundle.Run.Requirements, verdict.RequirementResult{ - ID: "OTHER", Obligations: []verdict.ObligationResult{{ID: "O", Verdict: verdict.Fail}}, - }) - bundle.Run.Requirements[0].Obligations = append(bundle.Run.Requirements[0].Obligations, - verdict.ObligationResult{ID: "PASS", Verdict: verdict.Pass}, - verdict.ObligationResult{ID: "SKIP", Verdict: verdict.Skipped}, - ) - bundle.Run.Requirements[0].Obligations[0].Evidence = []provider.Evidence{ - {ID: "b", Paths: []string{"z", "a"}, Passed: &failed}, - {ID: "a", Paths: []string{"a"}, Passed: &passed}, - } - bundle.Document = &ir.Document{Requirements: []ir.Requirement{ - {ID: "REQ", Intent: "first", Boundaries: ir.Boundaries{Allowed: []string{"b", "a"}, Forbidden: []string{"z"}}}, - {ID: "REQ-2", Intent: "second", Boundaries: ir.Boundaries{Allowed: []string{"c"}}}, - }} - packet := BuildPacket(bundle, 1, 2, "") - if len(packet.Failures) != 2 || len(packet.Failures[0].EvidenceIDs) != 2 || - packet.Failures[0].EvidenceIDs[0] != "a" || packet.Failures[0].Paths[0] != "a" || - packet.Intent != "REQ: first\n\nREQ-2: second" || len(packet.AllowedPaths) != 3 { - t.Fatalf("%+v", packet) - } -} - -func TestRunInterruptedReviewAndFinalizeFailure(t *testing.T) { - root := repairRepo(t) - store, err := evidence.NewStore(root, t.TempDir()) - if err != nil { - t.Fatal(err) - } - cfg := config.Default() - cancelled, cancel := context.WithCancel(context.Background()) - cancel() - outcome, err := Run(cancelled, Options{ - Root: root, Config: cfg, Store: store, MaxAttempts: 2, - Verify: func(context.Context) (*evidence.Bundle, error) { - return failedRepairBundle("cancelled", "attempt"), nil - }, - }) - if err != nil || outcome.Stopped != "interrupted" || outcome.Bundle.Run.Verdict != verdict.Error { - t.Fatalf("%+v %v", outcome, err) - } - - outcome, err = Run(context.Background(), Options{ - Root: root, Config: cfg, Store: store, MaxAttempts: 2, - Verify: func(context.Context) (*evidence.Bundle, error) { - bundle := failedRepairBundle("interrupted", "attempt") - bundle.Interrupted = true - return bundle, nil - }, - }) - if err != nil || outcome.Stopped != "interrupted" { - t.Fatalf("%+v %v", outcome, err) - } - outcome, err = Run(context.Background(), Options{ - Root: root, Config: cfg, Store: store, - Verify: func(context.Context) (*evidence.Bundle, error) { - return &evidence.Bundle{RunID: "review", Run: verdict.RunResult{Verdict: verdict.ReviewRequired}}, nil - }, - }) - if err != nil || outcome.Stopped != "review_required" { - t.Fatalf("%+v %v", outcome, err) - } - outcome, err = Run(context.Background(), Options{ - Root: root, Config: cfg, Store: store, - Verify: func(context.Context) (*evidence.Bundle, error) { - return &evidence.Bundle{RunID: "pass", Run: verdict.RunResult{Verdict: verdict.Pass}}, nil - }, - Finalize: func(*evidence.Bundle) error { return errors.New("finalize") }, - }) - if err == nil || outcome.ExitCode != exitcode.Internal { - t.Fatalf("%+v %v", outcome, err) - } -} - -func TestRunCaptureAndStoreStageFailures(t *testing.T) { - oldPatch := takePatch - defer func() { takePatch = oldPatch }() - for failureCall := 1; failureCall <= 2; failureCall++ { - root := repairRepo(t) - store, _ := evidence.NewStore(root, t.TempDir()) - calls := 0 - takePatch = func(root, storeRoot string) ([]byte, error) { - calls++ - if calls == failureCall { - return nil, errors.New("patch") - } - return oldPatch(root, storeRoot) - } - outcome, err := Run(context.Background(), Options{ - Root: root, Config: config.Default(), Store: store, MaxAttempts: 2, AgentCommand: "true", - Verify: func(context.Context) (*evidence.Bundle, error) { - return failedRepairBundle("patch-run", "attempt-001"), nil - }, - }) - if err == nil || outcome.ExitCode != exitcode.Internal { - t.Fatalf("call=%d outcome=%+v err=%v", failureCall, outcome, err) - } - } - takePatch = oldPatch - - stages := []struct { - name string - prepare func(*evidence.Store) error - command string - }{ - {"patch-before", func(store *evidence.Store) error { - return store.WriteRepairArtifact("run", "attempt-001", "patch-before.diff", []byte("conflict")) - }, "true"}, - {"stdout", func(store *evidence.Store) error { - return store.WriteAgentLog("run", "attempt-001", "stdout", []byte("conflict")) - }, "printf actual"}, - {"stderr", func(store *evidence.Store) error { - return store.WriteAgentLog("run", "attempt-001", "stderr", []byte("conflict")) - }, "printf actual >&2"}, - {"agent-exit", func(store *evidence.Store) error { - return store.WriteRepairArtifact("run", "attempt-001", "agent-exit.json", []byte("conflict")) - }, "true"}, - {"patch-after", func(store *evidence.Store) error { - return store.WriteRepairArtifact("run", "attempt-001", "patch-after.diff", []byte("conflict")) - }, "true"}, - } - for _, stage := range stages { - t.Run(stage.name, func(t *testing.T) { - root := repairRepo(t) - store, _ := evidence.NewStore(root, t.TempDir()) - if err := stage.prepare(store); err != nil { - t.Fatal(err) - } - outcome, err := Run(context.Background(), Options{ - Root: root, Config: config.Default(), Store: store, MaxAttempts: 2, - AgentCommand: stage.command, - Verify: func(context.Context) (*evidence.Bundle, error) { - return failedRepairBundle("run", "attempt-001"), nil - }, - }) - if err == nil || outcome.ExitCode != exitcode.Internal { - t.Fatalf("%+v %v", outcome, err) - } - }) - } -} - -func TestRunSnapshotStageFailures(t *testing.T) { - oldSnapshot := takeSnapshot - defer func() { takeSnapshot = oldSnapshot }() - for failureCall := 1; failureCall <= 2; failureCall++ { - root := repairRepo(t) - store, _ := evidence.NewStore(root, t.TempDir()) - calls := 0 - takeSnapshot = func(root string) (map[string]string, error) { - calls++ - if calls == failureCall { - return nil, errors.New("snapshot") - } - return oldSnapshot(root) - } - outcome, err := Run(context.Background(), Options{ - Root: root, Config: config.Default(), Store: store, MaxAttempts: 2, AgentCommand: "true", - Verify: func(context.Context) (*evidence.Bundle, error) { - return failedRepairBundle("snapshot-run", "attempt-001"), nil - }, - }) - if err == nil || outcome.ExitCode != exitcode.Internal { - t.Fatalf("call=%d outcome=%+v err=%v", failureCall, outcome, err) - } - } -} - -func TestRunAgentErrorsAndCancellation(t *testing.T) { - root := repairRepo(t) - store, _ := evidence.NewStore(root, t.TempDir()) - cfg := config.Default() - cfg.Repair.StopOnRepeatedDiff = false - cfg.Repair.StopOnRepeatedFailure = false - attempt := 0 - outcome, err := Run(context.Background(), Options{ - Root: root, Config: cfg, Store: store, MaxAttempts: 3, AgentCommand: "exit 7", - Verify: func(context.Context) (*evidence.Bundle, error) { - attempt++ - return failedRepairBundle("errors", "attempt-00"+string(rune('0'+attempt))), nil - }, - }) - if err != nil || outcome.Stopped != "repeated_agent_error" { - t.Fatalf("%+v %v", outcome, err) - } - if agentExitCode(errors.New("plain")) != -1 { - t.Fatal("plain error had process exit code") - } - command := exec.Command("sh", "-c", "exit 9") - processErr := command.Run() - if agentExitCode(processErr) != 9 { - t.Fatal(processErr) - } - - root = repairRepo(t) - store, _ = evidence.NewStore(root, t.TempDir()) - ctx, cancel := context.WithCancel(context.Background()) - marker := filepath.Join(root, "agent-started") - go func() { - deadline := time.Now().Add(time.Second) - for time.Now().Before(deadline) { - if _, err := os.Stat(marker); err == nil { - cancel() - return - } - time.Sleep(time.Millisecond) - } - }() - outcome, err = Run(ctx, Options{ - Root: root, Config: cfg, Store: store, MaxAttempts: 2, - AgentCommand: "touch agent-started; sleep 2", - Verify: func(context.Context) (*evidence.Bundle, error) { - return failedRepairBundle("cancel-agent", "attempt-001"), nil - }, - }) - if err != nil || outcome.Stopped != "interrupted" || !outcome.Bundle.Interrupted { - t.Fatalf("%+v %v", outcome, err) - } -} - -func TestCapturePatchFailuresAndUntrackedContent(t *testing.T) { - oldOutput := gitOutput - defer func() { gitOutput = oldOutput }() - gitOutput = func(string, ...string) ([]byte, error) { return nil, errors.New("tracked") } - if _, err := capturePatch(t.TempDir(), ""); err == nil { - t.Fatal("tracked diff error ignored") - } - calls := 0 - gitOutput = func(string, ...string) ([]byte, error) { - calls++ - if calls == 2 { - return nil, errors.New("untracked") - } - return nil, nil - } - if _, err := capturePatch(t.TempDir(), ""); err == nil { - t.Fatal("untracked listing error ignored") - } - root := t.TempDir() - calls = 0 - gitOutput = func(string, ...string) ([]byte, error) { - calls++ - if calls%2 == 0 { - return []byte("missing\n"), nil - } - return nil, nil - } - if _, err := capturePatch(root, ""); err == nil { - t.Fatal("untracked read error ignored") - } - if err := os.WriteFile(filepath.Join(root, "plain"), []byte("content"), 0o644); err != nil { - t.Fatal(err) - } - calls = 0 - gitOutput = func(string, ...string) ([]byte, error) { - calls++ - if calls%2 == 0 { - return []byte("\nplain\n"), nil - } - return []byte("tracked"), nil - } - raw, err := capturePatch(root, filepath.Join(root, "store")) - if err != nil || !strings.Contains(string(raw), "intentci-untracked plain") || - !strings.HasSuffix(string(raw), "\n") { - t.Fatalf("%q %v", raw, err) - } - if err := os.MkdirAll(filepath.Join(root, "store"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "store", "hidden"), []byte("secret"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "empty"), nil, 0o644); err != nil { - t.Fatal(err) - } - calls = 0 - gitOutput = func(string, ...string) ([]byte, error) { - calls++ - if calls%2 == 0 { - return []byte("store/hidden\nempty\nplain\n"), nil - } - return nil, nil - } - raw, err = capturePatch(root, filepath.Join(root, "store")) - if err != nil || strings.Contains(string(raw), "store/hidden") || - !strings.Contains(string(raw), "intentci-untracked empty") { - t.Fatalf("%q %v", raw, err) - } - if hashBytes([]byte("x")) == "" { - t.Fatal("empty hash") - } -} diff --git a/internal/repo/repo.go b/internal/repo/repo.go new file mode 100644 index 0000000..0462397 --- /dev/null +++ b/internal/repo/repo.go @@ -0,0 +1,74 @@ +package repo + +import ( + "bytes" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +func Root(start string) (string, error) { + output, err := git(start, "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("not a Git repository: %w", err) + } + root := strings.TrimSpace(string(output)) + if root == "" { + return "", fmt.Errorf("Git returned an empty repository root") + } + return filepath.Clean(root), nil +} + +func Changed(root string) ([]string, error) { + files := map[string]bool{} + if _, err := git(root, "rev-parse", "--verify", "--quiet", "HEAD"); err == nil { + output, err := git(root, "diff", "--name-only", "--no-renames", "-z", "HEAD", "--") + if err != nil { + return nil, err + } + add(files, output) + } else { + output, err := git(root, "ls-files", "--cached", "-z") + if err != nil { + return nil, err + } + add(files, output) + } + output, err := git(root, "ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + return nil, err + } + add(files, output) + + result := make([]string, 0, len(files)) + for file := range files { + result = append(result, file) + } + sort.Strings(result) + return result, nil +} + +func add(files map[string]bool, output []byte) { + for _, raw := range bytes.Split(output, []byte{0}) { + if len(raw) != 0 { + files[filepath.ToSlash(string(raw))] = true + } + } +} + +func git(root string, args ...string) ([]byte, error) { + command := exec.Command("git", append([]string{"-C", root}, args...)...) + var stderr bytes.Buffer + command.Stderr = &stderr + output, err := command.Output() + if err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = err.Error() + } + return nil, fmt.Errorf("git %s: %s", strings.Join(args, " "), detail) + } + return output, nil +} diff --git a/internal/repo/repo_test.go b/internal/repo/repo_test.go new file mode 100644 index 0000000..e1ec14a --- /dev/null +++ b/internal/repo/repo_test.go @@ -0,0 +1,106 @@ +package repo + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" +) + +func run(t *testing.T, root string, args ...string) { + t.Helper() + command := exec.Command(args[0], args[1:]...) + command.Dir = root + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, output) + } +} + +func write(t *testing.T, root, name, content string) { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func newGitRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + root, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + run(t, root, "git", "init", "-q") + run(t, root, "git", "config", "user.email", "intentci@example.com") + run(t, root, "git", "config", "user.name", "IntentCI") + return root +} + +func TestRootAndChanged(t *testing.T) { + root := newGitRepo(t) + write(t, root, ".gitignore", "ignored\n") + write(t, root, "old.go", "package old\n") + write(t, root, "delete.go", "package gone\n") + run(t, root, "git", "add", ".") + run(t, root, "git", "commit", "-qm", "initial") + + subdir := filepath.Join(root, "a", "b") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + gotRoot, err := Root(subdir) + if err != nil || gotRoot != root { + t.Fatalf("Root() = %q, %v; want %q", gotRoot, err, root) + } + if files, err := Changed(root); err != nil || len(files) != 0 { + t.Fatalf("clean Changed() = %v, %v", files, err) + } + + write(t, root, "old.go", "package changed\n") + run(t, root, "git", "add", "old.go") + write(t, root, "new.go", "package new\n") + write(t, root, "ignored", "ignore me\n") + if err := os.Remove(filepath.Join(root, "delete.go")); err != nil { + t.Fatal(err) + } + files, err := Changed(root) + if err != nil { + t.Fatal(err) + } + want := []string{"delete.go", "new.go", "old.go"} + if !reflect.DeepEqual(files, want) { + t.Fatalf("Changed() = %v, want %v", files, want) + } +} + +func TestChangedRenameAndUnbornRepository(t *testing.T) { + root := newGitRepo(t) + write(t, root, "before.go", "package before\n") + run(t, root, "git", "add", ".") + run(t, root, "git", "commit", "-qm", "initial") + run(t, root, "git", "mv", "before.go", "after.go") + files, err := Changed(root) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(files, []string{"after.go", "before.go"}) { + t.Fatalf("rename Changed() = %v", files) + } + + unborn := newGitRepo(t) + write(t, unborn, "staged.go", "package staged\n") + write(t, unborn, "untracked.go", "package untracked\n") + run(t, unborn, "git", "add", "staged.go") + files, err = Changed(unborn) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(files, []string{"staged.go", "untracked.go"}) { + t.Fatalf("unborn Changed() = %v", files) + } +} diff --git a/internal/report/report.go b/internal/report/report.go deleted file mode 100644 index e2040e5..0000000 --- a/internal/report/report.go +++ /dev/null @@ -1,228 +0,0 @@ -package report - -import ( - "encoding/json" - "encoding/xml" - "fmt" - "io" - "os" - "sort" - "strings" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/verdict" - appschema "github.com/hypertrial/intentci/pkg/schema" -) - -// Write renders a bundle in the requested format. -func Write(w io.Writer, format string, b *evidence.Bundle) error { - switch strings.ToLower(format) { - case "", "text": - return writeText(w, b) - case "json": - if err := appschema.Validate("report", b); err != nil { - return err - } - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(b) - case "junit": - return writeJUnit(w, b) - default: - return fmt.Errorf("unsupported format %q", format) - } -} - -func writeText(w io.Writer, b *evidence.Bundle) error { - var output strings.Builder - fmt.Fprintf(&output, "Run %s verdict=%s\n", b.RunID, b.Run.Verdict) - if len(b.Unmapped) > 0 { - fmt.Fprintf(&output, "Unmapped files: %s\n", strings.Join(b.Unmapped, ", ")) - } - for _, r := range b.Run.Requirements { - fmt.Fprintf(&output, "\n%s %s %s\n", strings.ToUpper(r.Verdict), r.ID, r.Title) - for _, o := range r.Obligations { - fmt.Fprintf(&output, " %s %s %s\n", strings.ToUpper(o.Verdict), o.ID, o.Statement) - if o.Reason != "" { - fmt.Fprintf(&output, " %s\n", o.Reason) - } - } - } - _, err := io.WriteString(w, output.String()) - return err -} - -type junitSuites struct { - XMLName xml.Name `xml:"testsuites"` - Suites []junitSuite `xml:"testsuite"` -} - -type junitSuite struct { - Name string `xml:"name,attr"` - Tests int `xml:"tests,attr"` - Failures int `xml:"failures,attr"` - Errors int `xml:"errors,attr"` - Cases []junitCase `xml:"testcase"` -} - -type junitCase struct { - Name string `xml:"name,attr"` - Classname string `xml:"classname,attr"` - Failure *junitFailure `xml:"failure,omitempty"` - Error *junitFailure `xml:"error,omitempty"` -} - -type junitFailure struct { - Message string `xml:"message,attr"` - Body string `xml:",chardata"` -} - -func writeJUnit(w io.Writer, b *evidence.Bundle) error { - suites := make([]junitSuite, 0, len(b.Run.Requirements)) - for _, r := range b.Run.Requirements { - suite := junitSuite{Name: r.ID} - for _, o := range r.Obligations { - suite.Tests++ - tc := junitCase{Name: o.ID, Classname: r.ID} - switch o.Verdict { - case verdict.Fail: - suite.Failures++ - tc.Failure = &junitFailure{Message: o.Verdict, Body: o.Reason} - case verdict.Error: - suite.Errors++ - tc.Error = &junitFailure{Message: o.Verdict, Body: o.Reason} - case verdict.Unproven, verdict.Uncertain, verdict.ReviewRequired: - suite.Failures++ - tc.Failure = &junitFailure{Message: o.Verdict, Body: o.Reason} - } - suite.Cases = append(suite.Cases, tc) - } - suites = append(suites, suite) - } - enc := xml.NewEncoder(w) - enc.Indent("", " ") - if err := enc.Encode(junitSuites{Suites: suites}); err != nil { - return err - } - _, err := io.WriteString(w, "\n") - return err -} - -// WriteGitHubStepSummary appends a markdown summary when GITHUB_STEP_SUMMARY is set. -func WriteGitHubStepSummary(b *evidence.Bundle) error { - path := os.Getenv("GITHUB_STEP_SUMMARY") - if path == "" { - return nil - } - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - fmt.Fprintf(f, "## IntentCI %s\n\nVerdict: `%s`\n\n", b.RunID, b.Run.Verdict) - fmt.Fprintf(f, "| Requirement | Verdict |\n| --- | --- |\n") - for _, r := range b.Run.Requirements { - fmt.Fprintf(f, "| %s | %s |\n", r.ID, r.Verdict) - } - return nil -} - -// Explain writes a detailed explanation for a requirement. -func Explain(w io.Writer, b *evidence.Bundle, id string, showEvidence bool) error { - return ExplainWithOptions(w, b, id, ExplainOptions{ShowEvidence: showEvidence}) -} - -// ExplainOptions controls optional evidence and log detail. -type ExplainOptions struct { - ShowEvidence bool - ShowLogs bool -} - -// ExplainWithOptions explains a run, requirement, obligation, or verifier. -func ExplainWithOptions(w io.Writer, b *evidence.Bundle, id string, options ExplainOptions) error { - if id == b.RunID { - _ = writeText(w, b) - if options.ShowLogs { - writeLogs(w, b, "") - } - return nil - } - for _, r := range b.Run.Requirements { - if r.ID == id { - fmt.Fprintf(w, "%s: %s\nVerdict: %s\n\n", r.ID, r.Title, strings.ToUpper(r.Verdict)) - for _, o := range r.Obligations { - writeObligation(w, o, options.ShowEvidence) - } - if options.ShowLogs { - writeLogs(w, b, r.ID+"/") - } - return nil - } - for _, o := range r.Obligations { - if o.ID == id { - fmt.Fprintf(w, "%s / %s\n", r.ID, r.Title) - writeObligation(w, o, options.ShowEvidence) - if options.ShowLogs { - writeLogs(w, b, r.ID+"/"+o.ID+"/") - } - return nil - } - for _, record := range o.Evidence { - if record.VerifierID == id || record.ID == id { - fmt.Fprintf(w, "%s / %s\n", r.ID, o.ID) - fmt.Fprintf(w, "%s [%s] %s\n", strings.ToUpper(record.Status), record.Class, record.Summary) - if options.ShowLogs { - writeLogs(w, b, r.ID+"/"+o.ID+"/"+record.VerifierID) - } - return nil - } - } - } - } - for key := range b.ProviderLogs { - if strings.HasSuffix(key, "/"+id) { - fmt.Fprintf(w, "Verifier %s\n", key) - writeLogs(w, b, key) - return nil - } - } - return fmt.Errorf("identifier %q not found in run %s", id, b.RunID) -} - -func writeObligation(w io.Writer, obligation verdict.ObligationResult, showEvidence bool) { - fmt.Fprintf(w, "%s %s\n %s\n", strings.ToUpper(obligation.Verdict), obligation.ID, obligation.Statement) - if obligation.Reason != "" { - fmt.Fprintf(w, " Evidence: %s\n", obligation.Reason) - } - if showEvidence { - for _, record := range obligation.Evidence { - fmt.Fprintf(w, " - [%s] %s\n", record.Class, record.Summary) - } - } - fmt.Fprintln(w) -} - -func writeLogs(w io.Writer, bundle *evidence.Bundle, prefix string) { - keys := make([]string, 0, len(bundle.ProviderLogs)) - for key := range bundle.ProviderLogs { - if prefix == "" || strings.HasPrefix(key, prefix) || key == prefix { - keys = append(keys, key) - } - } - sort.Strings(keys) - for _, key := range keys { - result := bundle.ProviderLogs[key] - if result.Stdout != "" { - fmt.Fprintf(w, "\n%s stdout:\n%s", key, result.Stdout) - if !strings.HasSuffix(result.Stdout, "\n") { - fmt.Fprintln(w) - } - } - if result.Stderr != "" { - fmt.Fprintf(w, "\n%s stderr:\n%s", key, result.Stderr) - if !strings.HasSuffix(result.Stderr, "\n") { - fmt.Fprintln(w) - } - } - } -} diff --git a/internal/report/report_coverage_test.go b/internal/report/report_coverage_test.go deleted file mode 100644 index 9431b8a..0000000 --- a/internal/report/report_coverage_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package report_test - -import ( - "bytes" - "errors" - "os" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/report" - "github.com/hypertrial/intentci/internal/verdict" -) - -type errWriter struct{} - -func (errWriter) Write([]byte) (int, error) { return 0, errors.New("write") } - -func TestWriteBranchesAndExplain(t *testing.T) { - b := &evidence.Bundle{ - RunID: "R", - Unmapped: []string{"u.txt"}, - Run: verdict.RunResult{ - Verdict: verdict.Fail, - Requirements: []verdict.RequirementResult{{ - ID: "REQ-1", Title: "t", Verdict: verdict.Fail, - Obligations: []verdict.ObligationResult{ - {ID: "O1", Verdict: verdict.Fail, Reason: "r", Statement: "s", Evidence: []provider.Evidence{{Class: "deterministic", Summary: "sum"}}}, - {ID: "O2", Verdict: verdict.Error, Reason: "e", Statement: "s"}, - {ID: "O3", Verdict: verdict.Unproven, Reason: "u", Statement: "s"}, - {ID: "O4", Verdict: verdict.Uncertain, Reason: "c", Statement: "s"}, - {ID: "O5", Verdict: verdict.ReviewRequired, Reason: "m", Statement: "s"}, - {ID: "O6", Verdict: verdict.Pass, Statement: "s"}, - }, - }}, - }, - } - var buf bytes.Buffer - if err := report.Write(&buf, "", b); err != nil { - t.Fatal(err) - } - if err := report.Write(&buf, "junit", b); err != nil { - t.Fatal(err) - } - if err := report.Write(errWriter{}, "junit", b); err == nil { - t.Fatal("junit write error") - } - if err := report.Write(&buf, "nope", b); err == nil { - t.Fatal("unsupported") - } - if err := report.Explain(&buf, b, "missing", false); err == nil { - t.Fatal("missing req") - } - if err := report.Explain(&buf, b, "REQ-1", true); err != nil { - t.Fatal(err) - } - t.Setenv("GITHUB_STEP_SUMMARY", "") - if err := report.WriteGitHubStepSummary(b); err != nil { - t.Fatal(err) - } - sum := filepath.Join(t.TempDir(), "sum.md") - t.Setenv("GITHUB_STEP_SUMMARY", sum) - if err := report.WriteGitHubStepSummary(b); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(sum); err != nil { - t.Fatal(err) - } - t.Setenv("GITHUB_STEP_SUMMARY", filepath.Join(t.TempDir(), "missing", "sum.md")) - if err := report.WriteGitHubStepSummary(b); err == nil { - t.Fatal("expected open error") - } -} diff --git a/internal/report/report_test.go b/internal/report/report_test.go deleted file mode 100644 index f1cd99f..0000000 --- a/internal/report/report_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package report_test - -import ( - "bytes" - "os" - "path/filepath" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/report" - "github.com/hypertrial/intentci/internal/verdict" -) - -func sample() *evidence.Bundle { - return &evidence.Bundle{ - RunID: "RUN1", CreatedAt: time.Now().UTC(), - Run: verdict.RunResult{ - Verdict: verdict.Fail, - Requirements: []verdict.RequirementResult{{ - ID: "REQ-1", Title: "t", Verdict: verdict.Fail, - Obligations: []verdict.ObligationResult{{ID: "OBL-1", Verdict: verdict.Fail, Reason: "x", Statement: "s"}}, - }}, - }, - } -} - -func TestFormats(t *testing.T) { - b := sample() - var buf bytes.Buffer - if err := report.Write(&buf, "text", b); err != nil { - t.Fatal(err) - } - buf.Reset() - if err := report.Write(&buf, "json", b); err != nil { - t.Fatal(err) - } - buf.Reset() - if err := report.Write(&buf, "junit", b); err != nil { - t.Fatal(err) - } - if err := report.Explain(&buf, b, "REQ-1", true); err != nil { - t.Fatal(err) - } - sum := filepath.Join(t.TempDir(), "sum.md") - t.Setenv("GITHUB_STEP_SUMMARY", sum) - if err := report.WriteGitHubStepSummary(b); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(sum); err != nil { - t.Fatal(err) - } -} diff --git a/internal/report/report_v1_test.go b/internal/report/report_v1_test.go deleted file mode 100644 index ef1acfb..0000000 --- a/internal/report/report_v1_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package report_test - -import ( - "bytes" - "errors" - "strings" - "testing" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/report" - "github.com/hypertrial/intentci/internal/verdict" -) - -type failAfterXML struct { - closed bool -} - -func (writer *failAfterXML) Write(value []byte) (int, error) { - if writer.closed { - return 0, errors.New("trailing newline") - } - if bytes.Contains(value, []byte("")) { - writer.closed = true - } - return len(value), nil -} - -func explanatoryBundle() *evidence.Bundle { - passed := true - record := provider.Evidence{ - ID: "evidence-id", VerifierID: "verifier-id", Status: "passed", - Class: "deterministic", Summary: "evidence summary", Passed: &passed, - } - return &evidence.Bundle{ - RunID: "run", - Run: verdict.RunResult{ - Verdict: verdict.Pass, - Requirements: []verdict.RequirementResult{{ - ID: "REQ", Title: "Requirement", Priority: "required", Verdict: verdict.Pass, - Obligations: []verdict.ObligationResult{{ - ID: "OBL", Statement: "Statement", Required: true, Verdict: verdict.Pass, - Evidence: []provider.Evidence{record}, - }}, - }}, - }, - ProviderLogs: map[string]provider.Result{ - "REQ/OBL/verifier-id": {Stdout: "stdout", Stderr: "stderr"}, - "REQ/OBL/fallback": {Stdout: "fallback\n"}, - "OTHER/unused": {Stdout: "unused"}, - }, - } -} - -func TestV1JSONValidationAndWriterErrors(t *testing.T) { - invalid := &evidence.Bundle{RunID: "run", Run: verdict.RunResult{Verdict: "invalid"}} - if err := report.Write(&bytes.Buffer{}, "json", invalid); err == nil { - t.Fatal("schema-invalid report written") - } - if err := report.Write(errWriter{}, "json", explanatoryBundle()); err == nil { - t.Fatal("JSON writer error ignored") - } - if err := report.Write(&failAfterXML{}, "junit", explanatoryBundle()); err == nil { - t.Fatal("JUnit trailing write error ignored") - } -} - -func TestV1ExplainEveryIdentifierAndLogs(t *testing.T) { - bundle := explanatoryBundle() - cases := []struct { - id string - evidence bool - logs bool - want string - }{ - {"run", false, true, "stdout"}, - {"REQ", true, true, "Requirement"}, - {"OBL", true, true, "evidence summary"}, - {"verifier-id", false, true, "PASSED"}, - {"evidence-id", false, false, "evidence summary"}, - {"fallback", false, false, "Verifier REQ/OBL/fallback"}, - } - for _, testCase := range cases { - t.Run(testCase.id, func(t *testing.T) { - var output bytes.Buffer - err := report.ExplainWithOptions(&output, bundle, testCase.id, report.ExplainOptions{ - ShowEvidence: testCase.evidence, ShowLogs: testCase.logs, - }) - if err != nil || !strings.Contains(output.String(), testCase.want) { - t.Fatalf("output=%q err=%v", output.String(), err) - } - }) - } -} diff --git a/internal/security/fuzz_test.go b/internal/security/fuzz_test.go deleted file mode 100644 index 5771486..0000000 --- a/internal/security/fuzz_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package security - -import ( - "crypto/sha256" - "encoding/hex" - "strings" - "testing" -) - -func FuzzV1Redaction(f *testing.F) { - f.Add([]byte("token"), "before %s after") - f.Add([]byte{0, 1, 2}, "TOKEN=%s") - f.Fuzz(func(t *testing.T, secretInput []byte, format string) { - if len(secretInput)+len(format) > 4096 { - t.Skip() - } - sum := sha256.Sum256(secretInput) - secret := "intentci-secret-" + hex.EncodeToString(sum[:]) - redactor := NewRedactor([]string{"TOKEN"}, []string{"TOKEN=" + secret}) - content := strings.ReplaceAll(format, "%s", secret) - redacted := redactor.Redact(content) - if strings.Contains(redacted, secret) { - t.Fatalf("secret remained after redaction: %q", redacted) - } - if twice := redactor.Redact(redacted); twice != redacted { - t.Fatalf("redaction is not idempotent: %q != %q", twice, redacted) - } - }) -} diff --git a/internal/security/security.go b/internal/security/security.go deleted file mode 100644 index b79ad95..0000000 --- a/internal/security/security.go +++ /dev/null @@ -1,223 +0,0 @@ -package security - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/bmatcuk/doublestar/v4" -) - -// PathViolationError identifies traversal, absolute-path, and symlink escapes. -type PathViolationError struct { - Message string -} - -func (e *PathViolationError) Error() string { return e.Message } - -// IsPathViolation reports whether an error represents an unsafe path. -func IsPathViolation(err error) bool { - var violation *PathViolationError - return errors.As(err, &violation) -} - -func pathViolationf(format string, values ...any) error { - return &PathViolationError{Message: fmt.Sprintf(format, values...)} -} - -var absolutePath = filepath.Abs -var evaluateSymlinks = filepath.EvalSymlinks - -// Redactor removes configured environment names and their current values from -// content before it reaches persistent evidence. -type Redactor struct { - replacements []string - names []string -} - -// NewRedactor builds a deterministic redactor from NAME=value entries. -func NewRedactor(patterns, environment []string) Redactor { - var redactor Redactor - for _, entry := range environment { - name, value, ok := strings.Cut(entry, "=") - if !ok || !matchAny(patterns, name) { - continue - } - redactor.names = append(redactor.names, name) - if value != "" { - redactor.replacements = append(redactor.replacements, value) - } - } - sort.Slice(redactor.replacements, func(i, j int) bool { - return len(redactor.replacements[i]) > len(redactor.replacements[j]) - }) - sort.Strings(redactor.names) - return redactor -} - -// Redact replaces secret values and conventional NAME=value renderings. -func (r Redactor) Redact(content string) string { - for _, value := range r.replacements { - content = strings.ReplaceAll(content, value, "[REDACTED]") - } - for _, name := range r.names { - for _, separator := range []string{"=", ": "} { - prefix := name + separator - start := 0 - for { - index := strings.Index(content[start:], prefix) - if index < 0 { - break - } - index += start + len(prefix) - end := index - for end < len(content) && content[end] != '\n' && content[end] != '\r' && - content[end] != ' ' && content[end] != '\t' && content[end] != ',' && - content[end] != '"' { - end++ - } - content = content[:index] + "[REDACTED]" + content[end:] - start = index + len("[REDACTED]") - } - } - } - return content -} - -// DefaultProtected are paths agents must not modify during repair (v1.md §23.2). -var DefaultProtected = []string{ - ".intentci/**", - ".github/workflows/**", -} - -// RedactEnv filters environment variable names matching glob patterns. -func RedactEnv(env []string, patterns []string) []string { - if len(patterns) == 0 { - return env - } - var out []string - for _, e := range env { - name, _, _ := strings.Cut(e, "=") - if matchAny(patterns, name) { - out = append(out, name+"=[REDACTED]") - continue - } - out = append(out, e) - } - return out -} - -func matchAny(patterns []string, s string) bool { - for _, p := range patterns { - ok, err := doublestar.Match(p, s) - if err == nil && ok { - return true - } - // also support simple substring wildcards already in doublestar - } - return false -} - -// ProtectedViolation returns changed protected paths. -func ProtectedViolation(changed []string, allowRequirementChanges bool, extraProtected []string) []string { - patterns := append([]string{}, DefaultProtected...) - patterns = append(patterns, extraProtected...) - var hits []string - for _, f := range changed { - f = filepath.ToSlash(f) - if allowRequirementChanges && isIntentciConfigPath(f) { - continue - } - for _, p := range patterns { - ok, err := doublestar.Match(filepath.ToSlash(p), f) - if err == nil && ok { - hits = append(hits, f) - break - } - } - } - return hits -} - -// BoundaryViolations returns files outside allowed paths or inside forbidden paths. -func BoundaryViolations(changed, allowed, forbidden []string) []string { - var hits []string - for _, file := range changed { - file = filepath.ToSlash(file) - if matchAny(forbidden, file) || (len(allowed) > 0 && !matchAny(allowed, file)) { - hits = append(hits, file) - } - } - return hits -} - -func isIntentciConfigPath(f string) bool { - f = filepath.ToSlash(f) - return f == ".intentci/config.yaml" || - strings.HasPrefix(f, ".intentci/requirements/") || - strings.HasPrefix(f, ".intentci/schemas/") || - strings.HasPrefix(f, ".intentci/policies/") -} - -// IsTestPath reports whether a path looks like a test file. -func IsTestPath(p string) bool { - p = filepath.ToSlash(strings.ToLower(p)) - return strings.Contains(p, "/tests/") || - strings.Contains(p, "_test.") || - strings.HasPrefix(p, "tests/") || - strings.Contains(p, "/test/") -} - -// ResolveInside resolves a repository-relative path without permitting traversal -// or symlink escape. Missing final paths are allowed when their parent is safe. -func ResolveInside(root, relative string) (string, error) { - if relative == "" || filepath.IsAbs(relative) { - return "", pathViolationf("path must be repository-relative: %q", relative) - } - rootAbs, err := absolutePath(root) - if err != nil { - return "", err - } - rootReal, err := evaluateSymlinks(rootAbs) - if err != nil { - return "", err - } - candidate := filepath.Join(rootReal, filepath.Clean(relative)) - if !inside(rootReal, candidate) { - return "", pathViolationf("path escapes repository: %q", relative) - } - resolved, err := evaluateSymlinks(candidate) - if err == nil { - if !inside(rootReal, resolved) { - return "", pathViolationf("symlink escapes repository: %q", relative) - } - return resolved, nil - } - if !os.IsNotExist(err) { - return "", err - } - parentPath := filepath.Dir(candidate) - var parent string - for { - parent, err = evaluateSymlinks(parentPath) - if err == nil { - break - } - if !os.IsNotExist(err) { - return "", err - } - parentPath = filepath.Dir(parentPath) - } - if !inside(rootReal, parent) { - return "", pathViolationf("symlink parent escapes repository: %q", relative) - } - return candidate, nil -} - -func inside(root, candidate string) bool { - rel, err := filepath.Rel(root, candidate) - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -} diff --git a/internal/security/security_coverage_test.go b/internal/security/security_coverage_test.go deleted file mode 100644 index dc65252..0000000 --- a/internal/security/security_coverage_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package security_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/security" -) - -func TestRedactEmptyAndProtectedAllow(t *testing.T) { - env := []string{"A=1"} - if got := security.RedactEnv(env, nil); len(got) != 1 || got[0] != "A=1" { - t.Fatal(got) - } - hits := security.ProtectedViolation([]string{".intentci/requirements/x.md", ".intentci/schemas/a.json", "main.go"}, true, []string{"extra/**"}) - for _, h := range hits { - if h == ".intentci/requirements/x.md" { - t.Fatalf("requirements should be allowed: %v", hits) - } - } - if !security.IsTestPath("tests/foo.go") || !security.IsTestPath("pkg/test/x.go") { - t.Fatal("test paths") - } - if security.IsTestPath("main.go") { - t.Fatal("not test") - } - boundary := security.BoundaryViolations( - []string{"src/ok.go", "outside.go", "src/secret.go"}, - []string{"src/**"}, - []string{"src/secret.go"}, - ) - if len(boundary) != 2 { - t.Fatalf("boundary violations: %v", boundary) - } -} diff --git a/internal/security/security_test.go b/internal/security/security_test.go deleted file mode 100644 index 58c035c..0000000 --- a/internal/security/security_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package security_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/security" -) - -func TestRedactAndProtected(t *testing.T) { - env := security.RedactEnv([]string{"PATH=/bin", "API_TOKEN=secret"}, []string{"*TOKEN*"}) - if env[1] != "API_TOKEN=[REDACTED]" { - t.Fatalf("%v", env) - } - hits := security.ProtectedViolation([]string{".intentci/requirements/REQ-001.md", "main.go"}, false, nil) - if len(hits) != 1 { - t.Fatalf("%v", hits) - } - hits = security.ProtectedViolation([]string{".github/workflows/ci.yml", "pkg/a.go"}, false, nil) - if len(hits) != 1 || hits[0] != ".github/workflows/ci.yml" { - t.Fatalf("workflows must be protected: %v", hits) - } - if !security.IsTestPath("internal/foo_test.go") { - t.Fatal("test path") - } -} diff --git a/internal/security/security_v1_internal_test.go b/internal/security/security_v1_internal_test.go deleted file mode 100644 index 21c30e9..0000000 --- a/internal/security/security_v1_internal_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package security - -import ( - "errors" - "os" - "path/filepath" - "testing" -) - -func TestResolveInsideFilesystemErrors(t *testing.T) { - if (&PathViolationError{Message: "unsafe"}).Error() != "unsafe" { - t.Fatal("path violation message") - } - oldAbs, oldEval := absolutePath, evaluateSymlinks - defer func() { - absolutePath = oldAbs - evaluateSymlinks = oldEval - }() - - absolutePath = func(string) (string, error) { return "", errors.New("absolute") } - if _, err := ResolveInside("root", "path"); err == nil { - t.Fatal("absolute error ignored") - } - absolutePath = oldAbs - - evaluateSymlinks = func(string) (string, error) { return "", errors.New("root") } - if _, err := ResolveInside(t.TempDir(), "path"); err == nil { - t.Fatal("root symlink error ignored") - } - - root := t.TempDir() - rootAbs, err := filepath.Abs(root) - if err != nil { - t.Fatal(err) - } - calls := 0 - evaluateSymlinks = func(path string) (string, error) { - calls++ - if calls == 1 { - return rootAbs, nil - } - return "", errors.New("candidate") - } - if _, err := ResolveInside(root, "path"); err == nil { - t.Fatal("candidate symlink error ignored") - } - - calls = 0 - evaluateSymlinks = func(path string) (string, error) { - calls++ - switch calls { - case 1: - return rootAbs, nil - case 2: - return "", os.ErrNotExist - default: - return "", errors.New("parent") - } - } - if _, err := ResolveInside(root, "missing/path"); err == nil { - t.Fatal("parent symlink error ignored") - } -} diff --git a/internal/security/security_v1_test.go b/internal/security/security_v1_test.go deleted file mode 100644 index 49d3ea8..0000000 --- a/internal/security/security_v1_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package security_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/hypertrial/intentci/internal/security" -) - -func TestRedactorRemovesNamesAndValues(t *testing.T) { - redactor := security.NewRedactor( - []string{"*TOKEN*", "*PASSWORD*"}, - []string{"API_TOKEN=long-secret", "PASSWORD=hunter2", "PATH=/bin"}, - ) - got := redactor.Redact("long-secret API_TOKEN=other PASSWORD: visible PATH=/bin") - if strings.Contains(got, "long-secret") || strings.Contains(got, "other") || - strings.Contains(got, "visible") || !strings.Contains(got, "PATH=/bin") { - t.Fatalf("redaction failed: %s", got) - } - if unchanged := security.NewRedactor(nil, nil).Redact("plain"); unchanged != "plain" { - t.Fatal(unchanged) - } -} - -func TestResolveInsidePathSafety(t *testing.T) { - root := t.TempDir() - rootReal, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(root, "safe"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "safe", "file"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - for _, relative := range []string{"safe/file", "safe/missing", "safe/missing/child"} { - path, err := security.ResolveInside(root, relative) - if err != nil || !strings.HasPrefix(path, rootReal) { - t.Fatalf("%s: path=%s err=%v", relative, path, err) - } - } - for _, unsafe := range []string{"", "../escape", filepath.Join(root, "safe", "file")} { - _, err := security.ResolveInside(root, unsafe) - if err == nil || !security.IsPathViolation(err) { - t.Fatalf("%q: %v", unsafe, err) - } - } - if _, err := security.ResolveInside(filepath.Join(root, "missing-root"), "x"); err == nil { - t.Fatal("missing root accepted") - } - - outside := t.TempDir() - if err := os.Symlink(outside, filepath.Join(root, "outside-link")); err != nil { - t.Fatal(err) - } - for _, relative := range []string{"outside-link", "outside-link/new"} { - _, err := security.ResolveInside(root, relative) - if err == nil || !security.IsPathViolation(err) { - t.Fatalf("%s: %v", relative, err) - } - } - if err := os.Symlink("safe", filepath.Join(root, "inside-link")); err != nil { - t.Fatal(err) - } - if _, err := security.ResolveInside(root, "inside-link/file"); err != nil { - t.Fatal(err) - } -} diff --git a/internal/verdict/benchmark_test.go b/internal/verdict/benchmark_test.go deleted file mode 100644 index 745a127..0000000 --- a/internal/verdict/benchmark_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package verdict_test - -import ( - "fmt" - "testing" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/verdict" -) - -func BenchmarkV1Aggregate10000TestCases(b *testing.B) { - obligations := make([]verdict.ObligationResult, 10_000) - for index := range obligations { - obligations[index] = verdict.ObligationResult{ - ID: fmt.Sprintf("OBL-%05d", index), Required: true, Verdict: verdict.Pass, - } - } - requirement := ir.Requirement{ID: "REQ-BENCHMARK", Priority: "required"} - b.ResetTimer() - for iteration := 0; iteration < b.N; iteration++ { - result := verdict.AggregateRequirement(requirement, obligations) - if result.Verdict != verdict.Pass { - b.Fatal(result.Verdict) - } - } -} diff --git a/internal/verdict/fuzz_test.go b/internal/verdict/fuzz_test.go deleted file mode 100644 index 205d5c6..0000000 --- a/internal/verdict/fuzz_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package verdict - -import ( - "slices" - "testing" -) - -func FuzzV1VerdictAggregation(f *testing.F) { - f.Add([]byte{0, 1, 2, 3, 4, 5, 6}) - f.Add([]byte{6, 0}) - f.Fuzz(func(t *testing.T, encoded []byte) { - if len(encoded) > 128 { - t.Skip() - } - values := []string{Pass, Skipped, Unproven, Uncertain, ReviewRequired, Error, Fail} - requirements := make([]RequirementResult, 0, len(encoded)) - for _, value := range encoded { - requirements = append(requirements, RequirementResult{ - ID: "R", Priority: "required", Verdict: values[int(value)%len(values)], - }) - } - forward := AggregateRun(requirements).Verdict - slices.Reverse(requirements) - reverse := AggregateRun(requirements).Verdict - if forward != reverse { - t.Fatalf("aggregation changed with ordering: %s != %s", forward, reverse) - } - }) -} diff --git a/internal/verdict/mutation_internal_test.go b/internal/verdict/mutation_internal_test.go deleted file mode 100644 index 3ecaecd..0000000 --- a/internal/verdict/mutation_internal_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package verdict - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" -) - -func TestMutationSensitiveEvidencePolicyBoundaries(t *testing.T) { - confidence := 0.5 - passed := true - node := ir.VerifyNode{Provider: &ir.ProviderSpec{ID: "check"}} - result := func(evidence []provider.Evidence, policy EvidencePolicy) string { - value, _, _ := EvaluateNodeWithPolicy(node, map[string]provider.Result{ - "check": {Status: "completed", Evidence: evidence}, - }, policy) - return value - } - if got := result([]provider.Evidence{{ - Class: "probabilistic", Confidence: &confidence, Passed: &passed, - }}, EvidencePolicy{Class: "probabilistic", ConfidenceThreshold: &confidence}); got != Pass { - t.Fatalf("confidence exactly at threshold = %s", got) - } - if got := result([]provider.Evidence{{ - Class: "probabilistic", Confidence: &confidence, Summary: "missing decision", - }}, EvidencePolicy{Class: "probabilistic", ConfidenceThreshold: &confidence}); got != Uncertain { - t.Fatalf("probabilistic evidence without a decision = %s", got) - } - if got := result([]provider.Evidence{{ - Class: "other", Passed: &passed, - }}, EvidencePolicy{Class: "deterministic"}); got != Uncertain { - t.Fatalf("non-deterministic evidence under deterministic policy = %s", got) - } - if got := result([]provider.Evidence{ - {Class: "deterministic", Data: map[string]any{"retry_superseded": true}}, - {Class: "deterministic", Passed: &passed}, - }, EvidencePolicy{Class: "deterministic"}); got != Pass { - t.Fatalf("superseded retry evidence = %s", got) - } -} - -func TestMutationSensitiveEqualRankKeepsFirstReason(t *testing.T) { - value, reason := worse(Fail, "first", Fail, "second") - if value != Fail || reason != "first" { - t.Fatalf("worse equal rank = %s %q", value, reason) - } - result := AggregateRequirement(ir.Requirement{ - ID: "REQ", Priority: "required", - }, []ObligationResult{ - {ID: "A", Required: true, Verdict: Fail, Reason: "first"}, - {ID: "B", Required: true, Verdict: Fail, Reason: "second"}, - }) - if result.Reason != "first" { - t.Fatalf("equal-rank aggregation reason = %q", result.Reason) - } -} diff --git a/internal/verdict/verdict.go b/internal/verdict/verdict.go deleted file mode 100644 index feb9ad8..0000000 --- a/internal/verdict/verdict.go +++ /dev/null @@ -1,318 +0,0 @@ -package verdict - -import ( - "cmp" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" -) - -// Obligation verdict values. -const ( - Pass = "pass" - Fail = "fail" - Unproven = "unproven" - Uncertain = "uncertain" - Skipped = "skipped" - ReviewRequired = "review_required" - Error = "error" -) - -// ObligationResult is the verdict for one obligation. -type ObligationResult struct { - ID string `json:"id"` - Statement string `json:"statement"` - Required bool `json:"required"` - Verdict string `json:"verdict"` - Reason string `json:"reason,omitempty"` - Evidence []provider.Evidence `json:"evidence,omitempty"` -} - -// RequirementResult is the aggregated requirement verdict. -type RequirementResult struct { - ID string `json:"id"` - Title string `json:"title"` - Priority string `json:"priority"` - Verdict string `json:"verdict"` - Reason string `json:"reason,omitempty"` - Obligations []ObligationResult `json:"obligations"` -} - -// RunResult is the overall run. -type RunResult struct { - Verdict string `json:"verdict"` - Requirements []RequirementResult `json:"requirements"` -} - -// EvidencePolicy controls whether non-deterministic evidence may satisfy an obligation. -type EvidencePolicy struct { - Class string - ConfidenceThreshold *float64 -} - -// EvaluateNode evaluates a verify expression given leaf results keyed by provider spec id. -func EvaluateNode(n ir.VerifyNode, leaves map[string]provider.Result) (string, string, []provider.Evidence) { - return EvaluateNodeWithPolicy(n, leaves, EvidencePolicy{}) -} - -// EvaluateNodeWithPolicy evaluates an expression with obligation evidence rules. -func EvaluateNodeWithPolicy(n ir.VerifyNode, leaves map[string]provider.Result, policy EvidencePolicy) (string, string, []provider.Evidence) { - if n.Provider != nil { - key := n.Provider.ID - if key == "" { - key = n.Provider.Provider - } - res, ok := leaves[key] - if !ok { - return Unproven, "missing provider evidence", nil - } - return leafVerdict(res, policy) - } - if len(n.All) > 0 { - var ev []provider.Evidence - worst := Pass - reason := "" - for _, c := range n.All { - v, r, e := EvaluateNodeWithPolicy(c, leaves, policy) - ev = append(ev, e...) - worst, reason = worse(worst, reason, v, r) - } - return worst, reason, ev - } - if len(n.Any) > 0 { - var ev []provider.Evidence - values := make([]string, 0, len(n.Any)) - reasons := make([]string, 0, len(n.Any)) - for _, c := range n.Any { - v, r, e := EvaluateNodeWithPolicy(c, leaves, policy) - ev = append(ev, e...) - values = append(values, v) - reasons = append(reasons, r) - if v == Pass { - return Pass, r, ev - } - } - allFailed := true - for _, value := range values { - if value != Fail { - allFailed = false - } - } - if allFailed { - return Fail, "all alternatives failed", ev - } - value, reason := Unproven, "no alternative produced sufficient evidence" - for index, candidate := range values { - if candidate == Fail { - continue - } - value, reason = worse(value, reason, candidate, reasons[index]) - } - return value, reason, ev - } - if n.Not != nil { - v, r, e := EvaluateNodeWithPolicy(*n.Not, leaves, policy) - switch v { - case Pass: - return Fail, "negation of pass", e - case Fail: - return Pass, "negation of fail", e - default: - return v, r, e - } - } - return Unproven, "empty verify expression", nil -} - -func leafVerdict(res provider.Result, policy EvidencePolicy) (string, string, []provider.Evidence) { - if res.Status == "error" { - return Error, firstDiag(res), res.Evidence - } - if res.Status == "skipped" { - return Skipped, "provider skipped", res.Evidence - } - if policy.Class == "human" { - return ReviewRequired, "obligation requires human evidence", res.Evidence - } - if policy.Class == "informational" { - return Unproven, "informational evidence cannot satisfy an obligation", res.Evidence - } - uncertainReason := "" - for _, e := range res.Evidence { - if e.Data != nil && e.Data["retry_superseded"] == true { - continue - } - if e.Class == "manual" || e.Class == "human" || (e.Data != nil && e.Data["review_required"] == true) { - return ReviewRequired, e.Summary, res.Evidence - } - if e.Class == "informational" { - continue - } - if e.Class == "probabilistic" { - if policy.Class != "probabilistic" || policy.ConfidenceThreshold == nil { - uncertainReason = "probabilistic evidence is not explicitly permitted" - } else if e.Confidence == nil || *e.Confidence < *policy.ConfidenceThreshold { - uncertainReason = "probabilistic confidence is below the required threshold" - } else if e.Passed == nil || !*e.Passed { - uncertainReason = e.Summary - } - } - if policy.Class == "deterministic" && e.Class != "deterministic" && e.Class != "informational" { - uncertainReason = "evidence does not meet the deterministic evidence requirement" - } - if e.Passed != nil && !*e.Passed { - if e.Class == "probabilistic" { - continue - } - return Fail, e.Summary, res.Evidence - } - } - if len(res.Evidence) == 0 { - return Unproven, "no evidence produced", nil - } - for _, e := range res.Evidence { - if e.Data != nil && e.Data["retry_superseded"] == true { - continue - } - if e.Class == "informational" { - return Unproven, "informational evidence cannot satisfy an obligation", res.Evidence - } - if e.Class == "probabilistic" { - continue - } - if e.Passed == nil { - return Unproven, "evidence missing pass/fail", res.Evidence - } - } - if uncertainReason != "" { - return Uncertain, uncertainReason, res.Evidence - } - return Pass, "all evidence passed", res.Evidence -} - -func firstDiag(res provider.Result) string { - if len(res.Diagnostics) > 0 { - return res.Diagnostics[0] - } - return "provider error" -} - -func worse(cur string, curReason string, next string, nextReason string) (string, string) { - if rank(next) > rank(cur) { - return next, nextReason - } - if curReason == "" { - return cur, nextReason - } - return cur, curReason -} - -func rank(v string) int { - switch v { - case Pass: - return 0 - case Skipped: - return 1 - case Unproven: - return 2 - case Uncertain: - return 3 - case ReviewRequired: - return 4 - case Error: - return 5 - case Fail: - return 6 - default: - return 2 - } -} - -// AggregateRequirement combines obligation verdicts. -// Optional (required: false) obligations never block the requirement by default. -func AggregateRequirement(r ir.Requirement, obs []ObligationResult) RequirementResult { - out := RequirementResult{ - ID: r.ID, Title: r.Title, Priority: r.Priority, Obligations: obs, Verdict: Pass, - } - hasRequired := false - for index := range obs { - o := obs[index] - if !o.Required { - continue - } - hasRequired = true - if o.Verdict == Skipped { - o.Verdict = Unproven - if o.Reason == "" { - o.Reason = "required obligation was not executed" - } - out.Obligations[index] = o - } - if rank(o.Verdict) > rank(out.Verdict) { - out.Verdict = o.Verdict - out.Reason = o.Reason - } - } - if len(obs) == 0 { - out.Verdict = Unproven - out.Reason = "no obligations selected" - } else if !hasRequired { - out.Verdict = Pass - out.Reason = "no required obligations" - } - return out -} - -// AggregateRun combines requirement verdicts. Only required priority blocks by default. -func AggregateRun(reqs []RequirementResult) RunResult { - if reqs == nil { - reqs = []RequirementResult{} - } - out := RunResult{Requirements: reqs, Verdict: Pass} - for _, r := range reqs { - switch r.Priority { - case "recommended", "informational": - continue - case "required": - if cmp.Compare(rank(r.Verdict), rank(out.Verdict)) == 1 { - out.Verdict = r.Verdict - } - default: - out.Verdict = Error - } - } - if len(reqs) == 0 { - out.Verdict = Pass - } - return out -} - -// ExitCode maps a run verdict to a process exit code (§17). -func ExitCode(v string) int { - switch v { - case Pass: - return 0 - case Fail: - return 1 - case Unproven: - return 2 - case Uncertain: - return 3 - case ReviewRequired: - return 4 - case Error: - return 6 - default: - return 7 - } -} - -// ExitCodeConfigured applies CI relaxation without changing the recorded verdict. -func ExitCodeConfigured(value string, failOn []string) int { - for _, configured := range failOn { - if configured == value { - return ExitCode(value) - } - } - return 0 -} diff --git a/internal/verdict/verdict_coverage_test.go b/internal/verdict/verdict_coverage_test.go deleted file mode 100644 index ac12afa..0000000 --- a/internal/verdict/verdict_coverage_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package verdict_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestLeafVerdictBranches(t *testing.T) { - cases := []struct { - name string - res provider.Result - want string - }{ - {"error diag", provider.Result{Status: "error", Diagnostics: []string{"boom"}}, verdict.Error}, - {"error no diag", provider.Result{Status: "error"}, verdict.Error}, - {"skipped", provider.Result{Status: "skipped"}, verdict.Skipped}, - {"manual", provider.Result{Status: "completed", Evidence: []provider.Evidence{{Class: "manual", Summary: "m"}}}, verdict.ReviewRequired}, - {"prob fail", provider.Result{Status: "completed", Evidence: []provider.Evidence{{Class: "probabilistic", Passed: boolPtr(false), Summary: "p"}}}, verdict.Uncertain}, - {"prob nil", provider.Result{Status: "completed", Evidence: []provider.Evidence{{Class: "probabilistic", Summary: "p"}}}, verdict.Uncertain}, - {"prob pass then fail", provider.Result{Status: "completed", Evidence: []provider.Evidence{ - {Class: "probabilistic", Passed: boolPtr(true), Summary: "ok"}, - {Class: "deterministic", Passed: boolPtr(false), Summary: "no"}, - }}, verdict.Fail}, - {"empty", provider.Result{Status: "completed"}, verdict.Unproven}, - {"nil passed", provider.Result{Status: "completed", Evidence: []provider.Evidence{{Class: "deterministic", Summary: "x"}}}, verdict.Unproven}, - {"pass", provider.Result{Status: "completed", Evidence: []provider.Evidence{{Class: "deterministic", Passed: boolPtr(true), Summary: "ok"}}}, verdict.Pass}, - } - for _, c := range cases { - v, _, _ := verdict.EvaluateNode(ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "command"}}, map[string]provider.Result{"command": c.res}) - if v != c.want { - t.Fatalf("%s: got %s want %s", c.name, v, c.want) - } - } -} - -func TestNotAnyEmptyAndWorse(t *testing.T) { - leaves := map[string]provider.Result{ - "ok": {Status: "completed", Evidence: []provider.Evidence{{Passed: boolPtr(true), Class: "deterministic", Summary: "ok"}}}, - "bad": {Status: "completed", Evidence: []provider.Evidence{{Passed: boolPtr(false), Class: "deterministic", Summary: "bad"}}}, - "err": {Status: "error", Diagnostics: []string{"e"}}, - } - v, _, _ := verdict.EvaluateNode(ir.VerifyNode{Not: &ir.VerifyNode{Provider: &ir.ProviderSpec{ID: "ok", Provider: "command"}}}, leaves) - if v != verdict.Fail { - t.Fatal(v) - } - v, _, _ = verdict.EvaluateNode(ir.VerifyNode{Not: &ir.VerifyNode{Provider: &ir.ProviderSpec{ID: "bad", Provider: "command"}}}, leaves) - if v != verdict.Pass { - t.Fatal(v) - } - v, _, _ = verdict.EvaluateNode(ir.VerifyNode{Not: &ir.VerifyNode{Provider: &ir.ProviderSpec{ID: "err", Provider: "command"}}}, leaves) - if v != verdict.Error { - t.Fatal(v) - } - v, _, _ = verdict.EvaluateNode(ir.VerifyNode{}, nil) - if v != verdict.Unproven { - t.Fatal(v) - } - v, _, _ = verdict.EvaluateNode(ir.VerifyNode{Any: []ir.VerifyNode{ - {Provider: &ir.ProviderSpec{ID: "bad", Provider: "command"}}, - {Provider: &ir.ProviderSpec{ID: "err", Provider: "command"}}, - }}, leaves) - if v != verdict.Error { - t.Fatal(v) - } - v, reason, _ := verdict.EvaluateNode(ir.VerifyNode{All: []ir.VerifyNode{ - {Provider: &ir.ProviderSpec{ID: "ok", Provider: "command"}}, - {Provider: &ir.ProviderSpec{ID: "ok", Provider: "command"}}, - }}, leaves) - if v != verdict.Pass || reason == "" { - t.Fatalf("%s %s", v, reason) - } -} - -func TestAggregateAndExitCodes(t *testing.T) { - rr := verdict.AggregateRequirement(ir.Requirement{ID: "R", Priority: "required"}, nil) - if rr.Verdict != verdict.Unproven { - t.Fatal(rr.Verdict) - } - rr = verdict.AggregateRequirement(ir.Requirement{ID: "R", Priority: "required"}, []verdict.ObligationResult{ - {Required: false, Verdict: verdict.Unproven}, - {Required: true, Verdict: verdict.Fail, Reason: "f"}, - }) - if rr.Verdict != verdict.Fail { - t.Fatal(rr.Verdict) - } - // exercise rank cases via aggregation - for _, v := range []string{verdict.Skipped, verdict.Uncertain, verdict.ReviewRequired, "custom"} { - rr = verdict.AggregateRequirement(ir.Requirement{ID: "R", Priority: "required"}, []verdict.ObligationResult{ - {Required: true, Verdict: verdict.Pass}, - {Required: true, Verdict: v}, - }) - if rr.Verdict != v && v != "custom" { - // custom maps to rank 2 (unproven-equivalent) but keeps string if worse - _ = rr - } - if v == "custom" && rr.Verdict != "custom" && rr.Verdict != verdict.Pass { - // rank(custom)=2 > rank(pass)=0 so verdict becomes custom - if rr.Verdict != "custom" { - t.Fatalf("custom got %s", rr.Verdict) - } - } - } - run := verdict.AggregateRun(nil) - if run.Verdict != verdict.Pass { - t.Fatal(run.Verdict) - } - if run.Requirements == nil { - t.Fatal("empty run requirements must be encoded as an empty JSON array") - } - run = verdict.AggregateRun([]verdict.RequirementResult{ - {Priority: "recommended", Verdict: verdict.Fail}, - {Priority: "required", Verdict: verdict.Error}, - }) - if run.Verdict != verdict.Error { - t.Fatal(run.Verdict) - } - codes := map[string]int{ - verdict.Pass: 0, verdict.Fail: 1, verdict.Unproven: 2, verdict.Uncertain: 3, - verdict.ReviewRequired: 4, verdict.Error: 6, "weird": 7, verdict.Skipped: 7, - } - for v, want := range codes { - if got := verdict.ExitCode(v); got != want { - t.Fatalf("%s: %d want %d", v, got, want) - } - } -} diff --git a/internal/verdict/verdict_test.go b/internal/verdict/verdict_test.go deleted file mode 100644 index 4dd17f0..0000000 --- a/internal/verdict/verdict_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package verdict_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -func boolPtr(b bool) *bool { return &b } - -func TestEvaluateAndAggregate(t *testing.T) { - leaves := map[string]provider.Result{ - "a": {Status: "completed", Evidence: []provider.Evidence{{Passed: boolPtr(true), Summary: "ok", Class: "deterministic"}}}, - "b": {Status: "completed", Evidence: []provider.Evidence{{Passed: boolPtr(false), Summary: "no", Class: "deterministic"}}}, - } - node := ir.VerifyNode{All: []ir.VerifyNode{ - {Provider: &ir.ProviderSpec{ID: "a", Provider: "command"}}, - {Provider: &ir.ProviderSpec{ID: "b", Provider: "command"}}, - }} - v, _, _ := verdict.EvaluateNode(node, leaves) - if v != verdict.Fail { - t.Fatalf("got %s", v) - } - anyNode := ir.VerifyNode{Any: []ir.VerifyNode{ - {Provider: &ir.ProviderSpec{ID: "a", Provider: "command"}}, - {Provider: &ir.ProviderSpec{ID: "b", Provider: "command"}}, - }} - v, _, _ = verdict.EvaluateNode(anyNode, leaves) - if v != verdict.Pass { - t.Fatalf("got %s", v) - } - unproven := verdict.AggregateRequirement(ir.Requirement{ID: "R", Title: "t", Priority: "required"}, []verdict.ObligationResult{ - {ID: "o", Required: true, Verdict: verdict.Unproven}, - }) - if unproven.Verdict != verdict.Unproven { - t.Fatal(unproven.Verdict) - } - optional := verdict.AggregateRequirement(ir.Requirement{ID: "R", Title: "t", Priority: "required"}, []verdict.ObligationResult{ - {ID: "opt", Required: false, Verdict: verdict.Fail, Reason: "optional fail"}, - {ID: "req", Required: true, Verdict: verdict.Pass}, - }) - if optional.Verdict != verdict.Pass { - t.Fatalf("optional fail must not block: %s", optional.Verdict) - } - onlyOptional := verdict.AggregateRequirement(ir.Requirement{ID: "R", Title: "t", Priority: "required"}, []verdict.ObligationResult{ - {ID: "opt", Required: false, Verdict: verdict.Fail}, - }) - if onlyOptional.Verdict != verdict.Pass || onlyOptional.Reason != "no required obligations" { - t.Fatalf("%+v", onlyOptional) - } - run := verdict.AggregateRun([]verdict.RequirementResult{unproven}) - if verdict.ExitCode(run.Verdict) != 2 { - t.Fatalf("exit=%d", verdict.ExitCode(run.Verdict)) - } -} - -func TestManualAndMissing(t *testing.T) { - leaves := map[string]provider.Result{ - "m": {Status: "completed", Evidence: []provider.Evidence{{Class: "manual", Summary: "review", Data: map[string]any{"review_required": true}}}}, - } - v, _, _ := verdict.EvaluateNode(ir.VerifyNode{Provider: &ir.ProviderSpec{ID: "m", Provider: "manual"}}, leaves) - if v != verdict.ReviewRequired { - t.Fatal(v) - } - v, _, _ = verdict.EvaluateNode(ir.VerifyNode{Provider: &ir.ProviderSpec{ID: "missing", Provider: "command"}}, nil) - if v != verdict.Unproven { - t.Fatal(v) - } -} - -func TestInvalidPriorityCannotProducePassingRun(t *testing.T) { - run := verdict.AggregateRun([]verdict.RequirementResult{{ - ID: "REQ-1", Priority: "requred", Verdict: verdict.Fail, - }}) - if run.Verdict != verdict.Error { - t.Fatalf("invalid priority produced %q", run.Verdict) - } -} diff --git a/internal/verdict/verdict_v1_test.go b/internal/verdict/verdict_v1_test.go deleted file mode 100644 index 69a01a3..0000000 --- a/internal/verdict/verdict_v1_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package verdict_test - -import ( - "testing" - - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -func TestV1AnyAllFailedAndEvidencePolicies(t *testing.T) { - node := ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "check"}} - result := func(evidence ...provider.Evidence) map[string]provider.Result { - return map[string]provider.Result{"check": {Status: "completed", Evidence: evidence}} - } - falseValue, trueValue := false, true - low, high, threshold := 0.4, 0.9, 0.8 - - anyNode := ir.VerifyNode{Any: []ir.VerifyNode{node, node}} - value, _, _ := verdict.EvaluateNode(anyNode, result(provider.Evidence{ - Class: "deterministic", Passed: &falseValue, Summary: "failed", - })) - if value != verdict.Fail { - t.Fatal(value) - } - - cases := []struct { - name string - policy verdict.EvidencePolicy - evidence []provider.Evidence - want string - }{ - {"human policy", verdict.EvidencePolicy{Class: "human"}, nil, verdict.ReviewRequired}, - {"informational policy", verdict.EvidencePolicy{Class: "informational"}, nil, verdict.Unproven}, - {"informational evidence", verdict.EvidencePolicy{}, []provider.Evidence{{ - Class: "informational", Passed: &trueValue, - }}, verdict.Unproven}, - {"probability not permitted", verdict.EvidencePolicy{}, []provider.Evidence{{ - Class: "probabilistic", Confidence: &high, Passed: &trueValue, - }}, verdict.Uncertain}, - {"probability missing confidence", verdict.EvidencePolicy{Class: "probabilistic", ConfidenceThreshold: &threshold}, []provider.Evidence{{ - Class: "probabilistic", Passed: &trueValue, - }}, verdict.Uncertain}, - {"probability low confidence", verdict.EvidencePolicy{Class: "probabilistic", ConfidenceThreshold: &threshold}, []provider.Evidence{{ - Class: "probabilistic", Confidence: &low, Passed: &trueValue, - }}, verdict.Uncertain}, - {"probability failed", verdict.EvidencePolicy{Class: "probabilistic", ConfidenceThreshold: &threshold}, []provider.Evidence{{ - Class: "probabilistic", Confidence: &high, Passed: &falseValue, Summary: "model failed", - }}, verdict.Uncertain}, - {"probability passed", verdict.EvidencePolicy{Class: "probabilistic", ConfidenceThreshold: &threshold}, []provider.Evidence{{ - Class: "probabilistic", Confidence: &high, Passed: &trueValue, - }}, verdict.Pass}, - {"deterministic mismatch", verdict.EvidencePolicy{Class: "deterministic"}, []provider.Evidence{{ - Class: "probabilistic", Confidence: &high, Passed: &trueValue, - }}, verdict.Uncertain}, - {"review flag", verdict.EvidencePolicy{}, []provider.Evidence{{ - Class: "deterministic", Data: map[string]any{"review_required": true}, - }}, verdict.ReviewRequired}, - {"superseded then pass", verdict.EvidencePolicy{}, []provider.Evidence{ - {Class: "deterministic", Passed: &falseValue, Data: map[string]any{"retry_superseded": true}}, - {Class: "deterministic", Passed: &trueValue}, - }, verdict.Pass}, - } - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - got, _, _ := verdict.EvaluateNodeWithPolicy(node, result(testCase.evidence...), testCase.policy) - if got != testCase.want { - t.Fatalf("got %s want %s", got, testCase.want) - } - }) - } -} - -func TestV1SkippedAggregationAndConfiguredExit(t *testing.T) { - requirement := verdict.AggregateRequirement(ir.Requirement{Priority: "required"}, []verdict.ObligationResult{{ - Required: true, Verdict: verdict.Skipped, - }}) - if requirement.Verdict != verdict.Unproven || - requirement.Obligations[0].Reason != "required obligation was not executed" { - t.Fatalf("%+v", requirement) - } - run := verdict.AggregateRun([]verdict.RequirementResult{{ - Priority: "required", Verdict: verdict.Skipped, - }}) - if run.Verdict != verdict.Skipped { - t.Fatal(run.Verdict) - } - if verdict.ExitCodeConfigured(verdict.Fail, []string{verdict.Error, verdict.Fail}) != 1 || - verdict.ExitCodeConfigured(verdict.Fail, []string{verdict.Error}) != 0 { - t.Fatal("configured exit mapping") - } -} diff --git a/internal/verify/verify.go b/internal/verify/verify.go deleted file mode 100644 index 52129ff..0000000 --- a/internal/verify/verify.go +++ /dev/null @@ -1,306 +0,0 @@ -package verify - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "path/filepath" - "time" - - "github.com/hypertrial/intentci/internal/compiler" - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/executor" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/git" - "github.com/hypertrial/intentci/internal/impact" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/report" - "github.com/hypertrial/intentci/internal/verdict" - appschema "github.com/hypertrial/intentci/pkg/schema" -) - -// Options configures verification. -type Options struct { - Root string - Base string - Head string - All bool - Changed bool - RequirementID string - ObligationID string - ProviderID string - MaxParallel int - MaxParallelSet bool - FailFast bool - FailFastSet bool - NoGit bool - NoCache bool - Format string - Config *config.Config - Document *ir.Document - RunID string - AttemptID string - AttemptOnly bool -} - -// Outcome is the verification result. -type Outcome struct { - Bundle *evidence.Bundle - ExitCode int -} - -// Run compiles, selects, executes, and persists a verification run. -func Run(ctx context.Context, opt Options) (*Outcome, error) { - cfg := opt.Config - if cfg == nil { - var err error - cfg, err = config.Load(opt.Root) - if err != nil { - return &Outcome{ExitCode: exitcode.CompileFailed}, err - } - } - document := opt.Document - if document == nil { - comp, err := compiler.Compile(compiler.Options{ - Root: opt.Root, Config: cfg, Strict: true, - }) - if err != nil { - return &Outcome{ExitCode: exitcode.CompileFailed}, err - } - document = comp.Document - } else if err := appschema.Validate("ir", document); err != nil { - return &Outcome{ExitCode: exitcode.CompileFailed}, err - } - - base := opt.Base - if base == "" { - base = cfg.BaseRefOr("origin/main") - } - useAll := opt.All || opt.RequirementID != "" - useChanged := opt.Changed || (!opt.All && opt.RequirementID == "") - if opt.All { - useChanged = false - useAll = true - } - - if err := validateSelectors(document, opt); err != nil { - return &Outcome{ExitCode: exitcode.Usage}, err - } - if opt.MaxParallelSet { - cfg.Verification.MaxParallel = opt.MaxParallel - } - if opt.FailFastSet { - cfg.Verification.FailFast = opt.FailFast - } - - var state *git.State - if opt.NoGit { - if useChanged { - return &Outcome{ExitCode: exitcode.Usage}, fmt.Errorf("--no-git requires --all or an explicit requirement") - } - state = &git.State{Root: opt.Root, HeadCommit: "unknown", DiffHash: ir.HashBytes(nil)} - } else { - st, gerr := git.ResolveWithOptions(opt.Root, git.ResolveOptions{ - BaseRef: base, HeadRef: opt.Head, IncludeUntracked: cfg.ChangeImpact.IncludeUntracked, - }) - if gerr != nil { - if useAll && !useChanged { - state = &git.State{Root: opt.Root, HeadCommit: "unknown", ChangedFiles: nil, DiffHash: ir.HashBytes(nil)} - } else { - return &Outcome{ExitCode: exitcode.VerifierError}, gerr - } - } else { - state = st - } - if cfg.Verification.RequireCleanWorktree && state.WorkingTreeDirty { - return &Outcome{ExitCode: exitcode.VerifierError}, fmt.Errorf("working tree must be clean") - } - } - - sel := impact.Select(document, impact.Options{ - All: useAll && !useChanged, - RequirementID: opt.RequirementID, - ObligationID: opt.ObligationID, - ChangedFiles: state.ChangedFiles, - GlobalPaths: cfg.ChangeImpact.GlobalPaths, - RunUnmappedRequirements: cfg.ChangeImpact.RunUnmappedRequirements, - }) - plan, _ := ir.BuildVerificationPlan(document, sel.Requirements) - - store, err := newStore(opt.Root, cfg.Evidence.Directory) - if err != nil { - return &Outcome{ExitCode: exitcode.Internal}, err - } - runID := opt.RunID - if runID == "" { - runID = evidence.NewRunID() - } - attemptID := opt.AttemptID - if attemptID == "" { - attemptID = "attempt-001" - } - cacheDir := filepath.Join(config.Dir(opt.Root), "cache") - - cfgHash := hashConfig(cfg) - providerResults, reqResults := executor.Run(ctx, sel.Requirements, executor.Options{ - Root: opt.Root, Config: cfg, Registry: provider.DefaultRegistry(), - BaseCommit: state.MergeBaseFull, HeadCommit: state.HeadCommit, - DiffHash: state.DiffHash, ChangedFiles: state.ChangedFiles, - Changes: providerChanges(state.Changes), - RunID: runID, AttemptID: attemptID, - EvidenceDir: filepath.Join(store.Dir(runID), "attempts", attemptID, "artifacts"), - IRHash: document.Hash, PlanHash: plan.Hash, ProviderID: opt.ProviderID, - NoCache: opt.NoCache, CacheDir: cacheDir, - }) - run := verdict.AggregateRun(reqResults) - if cfg.ChangeImpact.FailOnUnmapped && len(sel.Unmapped) > 0 { - run.Verdict = verdict.Fail - } - bundle := &evidence.Bundle{ - RunID: runID, AttemptID: attemptID, CreatedAt: time.Now().UTC(), Root: opt.Root, - BaseCommit: state.MergeBaseFull, HeadCommit: state.HeadCommit, - ConfigHash: cfgHash, IRHash: document.Hash, Document: document, - Run: run, Unmapped: sel.Unmapped, ProviderLogs: flattenProviderResults(providerResults), - RepositoryState: state, VerificationPlan: plan, - } - if ctx.Err() != nil { - bundle.Interrupted = true - bundle.Run.Verdict = verdict.Error - } - _ = useChanged // unmapped files are reported on the bundle for callers/CI - store.RedactPatterns = append([]string{}, cfg.Evidence.Redact.Environment...) - persist := persistBundle - if opt.AttemptOnly { - persist = func(store *evidence.Store, bundle *evidence.Bundle) error { - return store.WriteAttempt(bundle) - } - } - if err := persist(store, bundle); err != nil { - return &Outcome{Bundle: bundle, ExitCode: exitcode.Internal}, err - } - if bundle.Interrupted { - return &Outcome{Bundle: bundle, ExitCode: exitcode.VerifierError}, nil - } - if hasSecurityViolation(providerResults) { - return &Outcome{Bundle: bundle, ExitCode: exitcode.SecurityBoundary}, nil - } - return &Outcome{Bundle: bundle, ExitCode: verdict.ExitCodeConfigured(run.Verdict, cfg.CI.FailOn)}, nil -} - -func providerChanges(changes []git.Change) []provider.Change { - output := make([]provider.Change, 0, len(changes)) - for _, change := range changes { - output = append(output, provider.Change{ - Path: change.Path, OldPath: change.OldPath, Status: change.Status, - Additions: change.Additions, Deletions: change.Deletions, Binary: change.Binary, - OldMode: change.OldMode, NewMode: change.NewMode, - }) - } - return output -} - -// FinalizeBundle emits reports, the manifest, and the final verdict for a run -// whose immutable attempts have already been persisted. -func FinalizeBundle(store *evidence.Store, bundle *evidence.Bundle) error { - for _, item := range []struct { - format string - name string - }{ - {format: "text", name: "report.txt"}, - {format: "json", name: "report.json"}, - {format: "junit", name: "report.junit.xml"}, - } { - var output bytes.Buffer - if err := report.Write(&output, item.format, bundle); err != nil { - return err - } - if err := store.WriteReport(bundle.RunID, item.name, output.Bytes()); err != nil { - return err - } - } - return store.Finalize(bundle) -} - -func validateSelectors(document *ir.Document, options Options) error { - if options.RequirementID != "" && document.RequirementByID(options.RequirementID) == nil { - return fmt.Errorf("requirement %q not found", options.RequirementID) - } - obligationFound := options.ObligationID == "" - providerFound := options.ProviderID == "" - for _, requirement := range document.Requirements { - if options.RequirementID != "" && requirement.ID != options.RequirementID { - continue - } - for _, obligation := range requirement.Obligations { - if obligation.ID == options.ObligationID { - obligationFound = true - } - var walk func(ir.VerifyNode) - walk = func(node ir.VerifyNode) { - if node.Provider != nil && node.Provider.ID == options.ProviderID { - providerFound = true - } - for _, child := range node.All { - walk(child) - } - for _, child := range node.Any { - walk(child) - } - if node.Not != nil { - walk(*node.Not) - } - } - walk(obligation.Verify) - } - } - if !obligationFound { - return fmt.Errorf("obligation %q not found", options.ObligationID) - } - if !providerFound { - return fmt.Errorf("provider %q not found", options.ProviderID) - } - return nil -} - -func flattenProviderResults(results map[string]executor.LeafResult) map[string]provider.Result { - flattened := map[string]provider.Result{} - for requirement, leaves := range results { - for key, result := range leaves { - flattened[requirement+"/"+key] = result - } - } - return flattened -} - -func hasSecurityViolation(results map[string]executor.LeafResult) bool { - for _, leaves := range results { - for _, result := range leaves { - if result.SecurityViolation { - return true - } - } - } - return false -} - -func hashConfig(cfg *config.Config) string { - b, _ := json.Marshal(cfg) - sum := sha256.Sum256(b) - return hex.EncodeToString(sum[:]) -} - -var persistBundle = persistRunBundle - -func persistRunBundle(store *evidence.Store, bundle *evidence.Bundle) error { - if err := store.WriteAttempt(bundle); err != nil { - return err - } - return FinalizeBundle(store, bundle) -} - -var newStore = evidence.NewStore diff --git a/internal/verify/verify_coverage_test.go b/internal/verify/verify_coverage_test.go deleted file mode 100644 index 73302fc..0000000 --- a/internal/verify/verify_coverage_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package verify_test - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/initcmd" - "github.com/hypertrial/intentci/internal/verify" -) - -func TestVerifyChangedCompileFailAndGitFallback(t *testing.T) { - if _, err := verify.Run(context.Background(), verify.Options{Root: t.TempDir(), All: true}); err == nil { - t.Fatal("missing config") - } - - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - out, err := verify.Run(context.Background(), verify.Options{Root: root, All: true, NoCache: true}) - if err != nil { - t.Fatal(err) - } - if out.ExitCode != exitcode.Pass { - t.Fatalf("code=%d", out.ExitCode) - } - - if _, err := verify.Run(context.Background(), verify.Options{Root: root, Changed: true}); err == nil { - t.Fatal("expected git error") - } - - out, err = verify.Run(context.Background(), verify.Options{Root: root, RequirementID: "REQ-001", ObligationID: "OBL-001", NoCache: true}) - if err != nil { - t.Fatal(err) - } - if out.Bundle == nil { - t.Fatal("nil bundle") - } - - run := func(args ...string) { - t.Helper() - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } - run("git", "init") - run("git", "config", "user.email", "t@e.com") - run("git", "config", "user.name", "t") - run("git", "add", ".") - run("git", "commit", "-m", "init") - if err := os.WriteFile(filepath.Join(root, "orphan.txt"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - out, err = verify.Run(context.Background(), verify.Options{Root: root, Changed: true, Base: "HEAD", NoCache: true}) - if err != nil { - t.Fatal(err) - } - _ = out - - if err := os.WriteFile(filepath.Join(root, ".intentci", "requirements", "REQ-001.md"), []byte("bad"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := verify.Run(context.Background(), verify.Options{Root: root, All: true}); err == nil { - t.Fatal("expected compile fail") - } -} diff --git a/internal/verify/verify_internal_test.go b/internal/verify/verify_internal_test.go deleted file mode 100644 index 2718d43..0000000 --- a/internal/verify/verify_internal_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package verify - -import ( - "context" - "errors" - "os" - "testing" - - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/initcmd" -) - -func TestStoreAndPersistErrors(t *testing.T) { - root := t.TempDir() - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - oldStore, oldPersist := newStore, persistBundle - defer func() { newStore, persistBundle = oldStore, oldPersist }() - - newStore = func(string, string) (*evidence.Store, error) { - return nil, errors.New("store") - } - out, err := Run(context.Background(), Options{Root: root, All: true}) - if err == nil || out.ExitCode != exitcode.Internal { - t.Fatalf("%v %+v", err, out) - } - - newStore = oldStore - persistBundle = func(*evidence.Store, *evidence.Bundle) error { return os.ErrPermission } - out, err = Run(context.Background(), Options{Root: root, All: true, NoCache: true}) - if err == nil || out.ExitCode != exitcode.Internal { - t.Fatalf("%v %+v", err, out) - } -} diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go deleted file mode 100644 index 1a85277..0000000 --- a/internal/verify/verify_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package verify_test - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/initcmd" - "github.com/hypertrial/intentci/internal/verify" -) - -func TestVerifyAll(t *testing.T) { - root := t.TempDir() - run := func(args ...string) { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v: %s", err, out) - } - } - run("git", "init") - run("git", "config", "user.email", "t@e.com") - run("git", "config", "user.name", "t") - if err := initcmd.Run(initcmd.Options{Root: root}); err != nil { - t.Fatal(err) - } - run("git", "add", ".") - run("git", "commit", "-m", "init") - out, err := verify.Run(context.Background(), verify.Options{Root: root, All: true, NoCache: true}) - if err != nil { - t.Fatal(err) - } - if out.ExitCode != exitcode.Pass { - t.Fatalf("code=%d", out.ExitCode) - } - if _, err := os.Stat(filepath.Join(root, ".intentci", "runs", "latest")); err != nil { - t.Fatal(err) - } -} diff --git a/internal/verify/verify_v1_internal_test.go b/internal/verify/verify_v1_internal_test.go deleted file mode 100644 index de286b9..0000000 --- a/internal/verify/verify_v1_internal_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package verify - -import ( - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/executor" - "github.com/hypertrial/intentci/internal/exitcode" - "github.com/hypertrial/intentci/internal/git" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/verdict" -) - -func emptyDocument(t *testing.T) *ir.Document { - t.Helper() - document := &ir.Document{SchemaVersion: 1, Project: "project", Requirements: []ir.Requirement{}} - if err := document.ComputeHashes(); err != nil { - t.Fatal(err) - } - return document -} - -func verificationConfig(directory string) *config.Config { - cfg := config.Default() - cfg.Evidence.Directory = directory - return cfg -} - -func gitVerificationRepo(t *testing.T) string { - t.Helper() - root := t.TempDir() - for _, arguments := range [][]string{ - {"init"}, {"config", "user.email", "test@example.com"}, {"config", "user.name", "Test"}, - } { - command := exec.Command("git", arguments...) - command.Dir = root - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v: %s", arguments, err, output) - } - } - if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("base\n"), 0o644); err != nil { - t.Fatal(err) - } - for _, arguments := range [][]string{{"add", "."}, {"commit", "-m", "base"}} { - command := exec.Command("git", arguments...) - command.Dir = root - if output, err := command.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v: %s", arguments, err, output) - } - } - return root -} - -func TestPinnedDocumentNoGitOverridesAndInterruption(t *testing.T) { - root := t.TempDir() - cfg := verificationConfig("runs") - outcome, err := Run(context.Background(), Options{ - Root: root, Config: cfg, Document: emptyDocument(t), All: true, NoGit: true, - MaxParallel: 2, MaxParallelSet: true, FailFast: true, FailFastSet: true, - AttemptOnly: true, RunID: "run", AttemptID: "attempt", - }) - if err != nil || outcome.ExitCode != exitcode.Pass || - cfg.Verification.MaxParallel != 2 || !cfg.Verification.FailFast { - t.Fatalf("%+v %v", outcome, err) - } - if _, err := Run(context.Background(), Options{ - Root: root, Config: cfg, Document: emptyDocument(t), Changed: true, NoGit: true, - }); err == nil { - t.Fatal("changed mode accepted with --no-git") - } - - cancelled, cancel := context.WithCancel(context.Background()) - cancel() - outcome, err = Run(cancelled, Options{ - Root: t.TempDir(), Config: verificationConfig("runs"), Document: emptyDocument(t), - All: true, NoGit: true, AttemptOnly: true, RunID: "cancelled", AttemptID: "attempt", - }) - if err != nil || outcome.ExitCode != exitcode.VerifierError || - !outcome.Bundle.Interrupted || outcome.Bundle.Run.Verdict != verdict.Error { - t.Fatalf("%+v %v", outcome, err) - } -} - -func TestPinnedDocumentAndSelectorFailures(t *testing.T) { - invalid := &ir.Document{SchemaVersion: 2, Project: "project", Hash: "hash", Requirements: []ir.Requirement{}} - outcome, err := Run(context.Background(), Options{ - Root: t.TempDir(), Config: verificationConfig("runs"), Document: invalid, All: true, NoGit: true, - }) - if err == nil || outcome.ExitCode != exitcode.CompileFailed { - t.Fatalf("%+v %v", outcome, err) - } - document := emptyDocument(t) - for _, options := range []Options{ - {RequirementID: "missing"}, - {ObligationID: "missing"}, - {ProviderID: "missing"}, - } { - options.Root = t.TempDir() - options.Config = verificationConfig("runs") - options.Document = document - options.All = true - options.NoGit = true - outcome, err := Run(context.Background(), options) - if err == nil || outcome.ExitCode != exitcode.Usage { - t.Fatalf("%+v %v", outcome, err) - } - } -} - -func TestCleanWorktreeAndFailOnUnmapped(t *testing.T) { - root := gitVerificationRepo(t) - if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("dirty\n"), 0o644); err != nil { - t.Fatal(err) - } - cfg := verificationConfig("runs") - cfg.Verification.RequireCleanWorktree = true - outcome, err := Run(context.Background(), Options{ - Root: root, Config: cfg, Document: emptyDocument(t), All: true, Base: "HEAD", - }) - if err == nil || outcome.ExitCode != exitcode.VerifierError { - t.Fatalf("%+v %v", outcome, err) - } - - cfg.Verification.RequireCleanWorktree = false - cfg.ChangeImpact.FailOnUnmapped = true - document := &ir.Document{ - SchemaVersion: 1, Project: "project", - Requirements: []ir.Requirement{{ - ID: "REQ-1", Title: "Requirement", Status: "active", Priority: "required", - AppliesTo: ir.AppliesTo{Paths: []string{"src/**"}}, Intent: "intent", SourcePath: "requirement.md", - Obligations: []ir.Obligation{{ - ID: "OBL", Statement: "statement", Required: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "command", ID: "command", Run: "true", - }}, - }}, - }}, - } - if err := document.ComputeHashes(); err != nil { - t.Fatal(err) - } - outcome, err = Run(context.Background(), Options{ - Root: root, Config: cfg, Document: document, Changed: true, Base: "HEAD", - RunID: "unmapped", AttemptID: "attempt", - }) - if err != nil || outcome.Bundle.Run.Verdict != verdict.Fail || len(outcome.Bundle.Unmapped) == 0 { - t.Fatalf("%+v %v", outcome, err) - } -} - -func TestSecurityViolationAndConversionHelpers(t *testing.T) { - document := &ir.Document{ - SchemaVersion: 1, Project: "project", - Requirements: []ir.Requirement{{ - ID: "REQ-1", Title: "Requirement", Status: "active", Priority: "required", - Intent: "intent", SourcePath: "requirement.md", - Obligations: []ir.Obligation{{ - ID: "OBL", Statement: "statement", Required: true, - Verify: ir.VerifyNode{Provider: &ir.ProviderSpec{ - Provider: "json", ID: "json", Report: "../escape", - Assert: map[string]any{"value": true}, - }}, - }}, - }}, - } - if err := document.ComputeHashes(); err != nil { - t.Fatal(err) - } - outcome, err := Run(context.Background(), Options{ - Root: t.TempDir(), Config: verificationConfig("runs"), Document: document, - All: true, NoGit: true, RunID: "security", AttemptID: "attempt", - }) - if err != nil || outcome.ExitCode != exitcode.SecurityBoundary { - t.Fatalf("%+v %v", outcome, err) - } - - changes := providerChanges([]git.Change{{ - Path: "new", OldPath: "old", Status: "renamed", Additions: 1, Deletions: 2, - Binary: true, OldMode: "1", NewMode: "2", - }}) - if len(changes) != 1 || changes[0].OldPath != "old" || !changes[0].Binary { - t.Fatal(changes) - } - flattened := flattenProviderResults(map[string]executor.LeafResult{ - "REQ": {"OBL/provider": {Provider: "p"}}, - }) - if flattened["REQ/OBL/provider"].Provider != "p" { - t.Fatal(flattened) - } - if !hasSecurityViolation(map[string]executor.LeafResult{ - "REQ": {"x": {SecurityViolation: true}}, - }) || hasSecurityViolation(map[string]executor.LeafResult{"REQ": {"x": {}}}) { - t.Fatal("security aggregation") - } - if hashConfig(config.Default()) == "" { - t.Fatal("config hash") - } -} - -func TestSelectorTraversal(t *testing.T) { - document := &ir.Document{Requirements: []ir.Requirement{ - {ID: "OTHER"}, - { - ID: "REQ", Obligations: []ir.Obligation{{ - ID: "OBL", Verify: ir.VerifyNode{ - All: []ir.VerifyNode{{Provider: &ir.ProviderSpec{ID: "all"}}}, - Any: []ir.VerifyNode{{Provider: &ir.ProviderSpec{ID: "any"}}}, - Not: &ir.VerifyNode{Provider: &ir.ProviderSpec{ID: "not"}}, - }, - }}, - }, - }} - for _, providerID := range []string{"all", "any", "not"} { - if err := validateSelectors(document, Options{ - RequirementID: "REQ", ObligationID: "OBL", ProviderID: providerID, - }); err != nil { - t.Fatal(err) - } - } - for _, options := range []Options{ - {RequirementID: "missing"}, - {RequirementID: "REQ", ObligationID: "missing"}, - {RequirementID: "REQ", ObligationID: "OBL", ProviderID: "missing"}, - } { - if err := validateSelectors(document, options); err == nil { - t.Fatalf("%+v", options) - } - } -} - -func TestFinalizeBundleFailureStages(t *testing.T) { - root := t.TempDir() - store, err := evidence.NewStore(root, "runs") - if err != nil { - t.Fatal(err) - } - invalid := &evidence.Bundle{RunID: "invalid", Run: verdict.RunResult{Verdict: "invalid"}} - if err := FinalizeBundle(store, invalid); err == nil { - t.Fatal("invalid report finalized") - } - - finalized := &evidence.Bundle{RunID: "finalized", Run: verdict.RunResult{Verdict: verdict.Pass}} - if err := store.WriteBundle(finalized); err != nil { - t.Fatal(err) - } - if err := FinalizeBundle(store, finalized); err == nil { - t.Fatal("finalized run reports overwritten") - } - - symlinked := &evidence.Bundle{RunID: "symlinked", Run: verdict.RunResult{Verdict: verdict.Pass}} - runDir := store.Dir("symlinked") - if err := os.MkdirAll(runDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(root, filepath.Join(runDir, "link")); err != nil { - t.Fatal(err) - } - if err := FinalizeBundle(store, symlinked); err == nil { - t.Fatal("symlinked evidence finalized") - } - - badRunDir := filepath.Join(store.Root, "bad-write") - if err := os.WriteFile(badRunDir, []byte("file"), 0o644); err != nil { - t.Fatal(err) - } - if err := persistRunBundle(store, &evidence.Bundle{ - RunID: "bad-write", Run: verdict.RunResult{Verdict: verdict.Pass}, - }); err == nil { - t.Fatal("attempt write error ignored") - } -} - -func TestGitFallbackAndErrorModes(t *testing.T) { - document := emptyDocument(t) - outcome, err := Run(context.Background(), Options{ - Root: t.TempDir(), Config: verificationConfig("runs"), Document: document, - All: true, RunID: "fallback", - }) - if err != nil || outcome.ExitCode != exitcode.Pass || outcome.Bundle.RepositoryState.HeadCommit != "unknown" { - t.Fatalf("%+v %v", outcome, err) - } - outcome, err = Run(context.Background(), Options{ - Root: t.TempDir(), Config: verificationConfig("runs"), Document: document, Changed: true, - }) - if err == nil || outcome.ExitCode != exitcode.VerifierError || - !strings.Contains(err.Error(), "git") { - t.Fatalf("%+v %v", outcome, err) - } -} - -func TestPersistHookReturnsBundle(t *testing.T) { - root := t.TempDir() - oldPersist := persistBundle - defer func() { persistBundle = oldPersist }() - persistBundle = func(*evidence.Store, *evidence.Bundle) error { return errors.New("persist") } - outcome, err := Run(context.Background(), Options{ - Root: root, Config: verificationConfig("runs"), Document: emptyDocument(t), - All: true, NoGit: true, - }) - if err == nil || outcome.Bundle == nil || outcome.ExitCode != exitcode.Internal { - t.Fatalf("%+v %v", outcome, err) - } -} diff --git a/internal/version/version.go b/internal/version/version.go index 3b02fd7..5167ace 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,12 +1,10 @@ package version -// Version is set via ldflags at release time. -var Version = "1.1.1" +var Version = "2.0.0" -// String returns the version string. func String() string { if Version == "" { - return "1.1.1" + return "2.0.0" } return Version } diff --git a/internal/version/version_internal_test.go b/internal/version/version_internal_test.go deleted file mode 100644 index 38eddc9..0000000 --- a/internal/version/version_internal_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package version - -import "testing" - -func TestStringBranches(t *testing.T) { - old := Version - defer func() { Version = old }() - Version = "9.9.9" - if String() != "9.9.9" { - t.Fatal(String()) - } - Version = "" - if String() != "1.1.1" { - t.Fatal(String()) - } -} diff --git a/internal/version/version_test.go b/internal/version/version_test.go index aaee656..cade6bc 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -1,21 +1,16 @@ -package version_test +package version -import ( - "testing" - - "github.com/hypertrial/intentci/internal/version" -) +import "testing" func TestString(t *testing.T) { - old := version.Version - defer func() { version.Version = old }() - - version.Version = "1.1.1" - if version.String() != "1.1.1" { - t.Fatalf("got %q", version.String()) + old := Version + defer func() { Version = old }() + Version = "test" + if String() != "test" { + t.Fatal(String()) } - version.Version = "" - if version.String() != "1.1.1" { - t.Fatalf("got %q", version.String()) + Version = "" + if String() != "2.0.0" { + t.Fatal(String()) } } diff --git a/pkg/schema/evidence.schema.json b/pkg/schema/evidence.schema.json deleted file mode 100644 index 83c8f00..0000000 --- a/pkg/schema/evidence.schema.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://intentci.dev/schemas/evidence-v1.json", - "title": "IntentCI Evidence Record", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "id", - "run_id", - "attempt_id", - "requirement_id", - "obligation_id", - "verifier_id", - "provider", - "provider_version", - "class", - "status", - "summary", - "repository_commit", - "base_commit", - "diff_hash", - "requirement_hash", - "obligation_hash", - "verification_plan_hash", - "started_at", - "completed_at" - ], - "properties": { - "schema_version": { "type": "string", "pattern": "^1(\\.[0-9]+)?$" }, - "id": { "type": "string", "minLength": 1 }, - "run_id": { "type": "string", "minLength": 1 }, - "attempt_id": { "type": "string", "minLength": 1 }, - "requirement_id": { "type": "string", "minLength": 1 }, - "obligation_id": { "type": "string", "minLength": 1 }, - "verifier_id": { "type": "string", "minLength": 1 }, - "provider": { "type": "string", "minLength": 1 }, - "provider_version": { "type": "string", "minLength": 1 }, - "class": { "enum": ["deterministic", "probabilistic", "human", "informational"] }, - "confidence": { "type": "number", "minimum": 0, "maximum": 1 }, - "strength": { "type": "string" }, - "status": { "enum": ["passed", "failed", "error", "skipped", "unknown"] }, - "summary": { "type": "string" }, - "paths": { "type": "array", "items": { "type": "string" } }, - "passed": { "type": ["boolean", "null"] }, - "data": { "type": "object" }, - "repository_commit": { "type": "string" }, - "base_commit": { "type": "string" }, - "diff_hash": { "type": "string" }, - "requirement_hash": { "type": "string" }, - "obligation_hash": { "type": "string" }, - "verification_plan_hash": { "type": "string" }, - "started_at": { "type": "string", "format": "date-time" }, - "completed_at": { "type": "string", "format": "date-time" }, - "source_evidence_hash": { "type": "string" }, - "artifacts": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["path", "sha256"], - "properties": { - "path": { "type": "string", "minLength": 1 }, - "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "media_type": { "type": "string" } - } - } - } - } -} diff --git a/pkg/schema/ir.schema.json b/pkg/schema/ir.schema.json deleted file mode 100644 index f1c6613..0000000 --- a/pkg/schema/ir.schema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://intentci.dev/schemas/ir-v1.json", - "title": "IntentCI Compiled Intent IR", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "project", "hash", "requirements"], - "properties": { - "schema_version": { "const": 1 }, - "project": { "type": "string", "minLength": 1 }, - "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "requirements": { - "type": "array", - "items": { "$ref": "requirement-v1.json" } - } - } -} diff --git a/pkg/schema/plan.schema.json b/pkg/schema/plan.schema.json deleted file mode 100644 index 877f52a..0000000 --- a/pkg/schema/plan.schema.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://intentci.dev/schemas/verification-plan-v1.json", - "title": "IntentCI Verification Plan", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "ir_hash", "hash", "requirements"], - "properties": { - "schema_version": { "const": 1 }, - "ir_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "requirements": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "hash", "obligations"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "obligations": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "hash", "verify"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "verify": { "type": "object" } - } - } - } - } - } - } - } -} diff --git a/pkg/schema/repair.schema.json b/pkg/schema/repair.schema.json deleted file mode 100644 index b56607f..0000000 --- a/pkg/schema/repair.schema.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://intentci.dev/schemas/repair-packet-v1.json", - "title": "IntentCI Repair Packet", - "type": "object", - "additionalProperties": false, - "required": ["run_id", "verdict", "failures", "attempt", "max_attempts"], - "properties": { - "run_id": { "type": "string", "minLength": 1 }, - "requirement_id": { "type": "string" }, - "verdict": { "$ref": "verdict-v1.json" }, - "summary": { "type": "string" }, - "intent": { "type": "string" }, - "allowed_paths": { "type": "array", "items": { "type": "string" } }, - "forbidden_paths": { "type": "array", "items": { "type": "string" } }, - "protected_paths": { "type": "array", "items": { "type": "string" } }, - "test_changes_allowed": { "type": "boolean" }, - "instructions": { "type": "array", "items": { "type": "string" } }, - "failures": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["requirement_id", "obligation_id", "verdict", "reason"], - "properties": { - "requirement_id": { "type": "string", "minLength": 1 }, - "obligation_id": { "type": "string", "minLength": 1 }, - "verdict": { "$ref": "verdict-v1.json" }, - "reason": { "type": "string" }, - "evidence_ids": { "type": "array", "items": { "type": "string" } }, - "paths": { "type": "array", "items": { "type": "string" } } - } - } - }, - "attempt": { "type": "integer", "minimum": 1 }, - "max_attempts": { "type": "integer", "minimum": 1 } - } -} diff --git a/pkg/schema/report.schema.json b/pkg/schema/report.schema.json deleted file mode 100644 index 602f919..0000000 --- a/pkg/schema/report.schema.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://intentci.dev/schemas/report-v1.json", - "title": "IntentCI Stable JSON Report", - "type": "object", - "required": ["run_id", "created_at", "run"], - "properties": { - "run_id": { "type": "string", "minLength": 1 }, - "created_at": { "type": "string", "format": "date-time" }, - "root": { "type": "string" }, - "base_commit": { "type": "string" }, - "head_commit": { "type": "string" }, - "config_hash": { "type": "string" }, - "ir_hash": { "type": "string" }, - "manifest_hash": { "type": "string" }, - "attempt_id": { "type": "string" }, - "interrupted": { "type": "boolean" }, - "document": { "$ref": "ir-v1.json" }, - "run": { - "type": "object", - "required": ["verdict", "requirements"], - "properties": { - "verdict": { "$ref": "verdict-v1.json" }, - "requirements": { "type": "array" } - } - }, - "provider_results": { "type": "object" }, - "repository_state": { "type": ["object", "null"] }, - "unmapped_files": { "type": "array", "items": { "type": "string" } } - } -} diff --git a/pkg/schema/requirement.schema.json b/pkg/schema/requirement.schema.json deleted file mode 100644 index f7c6ec6..0000000 --- a/pkg/schema/requirement.schema.json +++ /dev/null @@ -1,182 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://intentci.dev/schemas/requirement-v1.json", - "title": "IntentCI Requirement (compiled)", - "type": "object", - "additionalProperties": false, - "required": ["id", "title", "status", "priority", "intent", "obligations", "source_path", "hash"], - "properties": { - "id": { "$ref": "#/$defs/id" }, - "title": { "type": "string", "minLength": 1 }, - "status": { "enum": ["active", "draft", "deprecated", "superseded", "disabled"] }, - "priority": { "enum": ["required", "recommended", "informational"] }, - "owners": { "$ref": "#/$defs/strings" }, - "depends_on": { "$ref": "#/$defs/ids" }, - "applies_to": { - "type": "object", - "additionalProperties": false, - "properties": { - "paths": { "$ref": "#/$defs/strings" }, - "symbols": { "$ref": "#/$defs/strings" } - } - }, - "tags": { "$ref": "#/$defs/strings" }, - "timeout": { "$ref": "#/$defs/duration" }, - "intent": { "type": "string", "minLength": 1 }, - "rationale": { "type": "string" }, - "constraints": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "kind", "statement"], - "properties": { - "id": { "$ref": "#/$defs/id" }, - "kind": { "enum": ["must", "must_not"] }, - "statement": { "type": "string", "minLength": 1 } - } - } - }, - "boundaries": { - "type": "object", - "additionalProperties": false, - "properties": { - "allowed": { "$ref": "#/$defs/strings" }, - "forbidden": { "$ref": "#/$defs/strings" } - } - }, - "obligations": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/obligation" } - }, - "source_path": { "type": "string", "minLength": 1 }, - "hash": { "$ref": "#/$defs/hash" } - }, - "$defs": { - "id": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" - }, - "ids": { - "type": "array", - "uniqueItems": true, - "items": { "$ref": "#/$defs/id" } - }, - "strings": { - "type": "array", - "items": { "type": "string", "minLength": 1 } - }, - "duration": { - "type": "string", - "minLength": 2 - }, - "hash": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "retry": { - "type": "object", - "additionalProperties": false, - "properties": { - "attempts": { "type": "integer", "minimum": 1 }, - "backoff": { "$ref": "#/$defs/duration" } - } - }, - "obligation": { - "type": "object", - "additionalProperties": false, - "required": ["id", "statement", "required", "verify", "hash"], - "properties": { - "id": { "$ref": "#/$defs/id" }, - "statement": { "type": "string", "minLength": 1 }, - "required": { "type": "boolean" }, - "description": { "type": "string" }, - "rationale": { "type": "string" }, - "evidence_class": { "enum": ["deterministic", "probabilistic", "human", "informational"] }, - "confidence_threshold": { "type": "number", "minimum": 0, "maximum": 1 }, - "timeout": { "$ref": "#/$defs/duration" }, - "retry": { "$ref": "#/$defs/retry" }, - "platforms": { - "type": "array", - "uniqueItems": true, - "items": { "enum": ["linux", "darwin"] } - }, - "tags": { "$ref": "#/$defs/strings" }, - "depends_on": { "$ref": "#/$defs/ids" }, - "manual_review": { "type": "boolean" }, - "severity": { "enum": ["error", "warning", "note"] }, - "verify": { "$ref": "#/$defs/verify" }, - "hash": { "$ref": "#/$defs/hash" } - } - }, - "verify": { - "type": "object", - "additionalProperties": false, - "properties": { - "all": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/verify" } - }, - "any": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/verify" } - }, - "not": { "$ref": "#/$defs/verify" }, - "provider": { "$ref": "#/$defs/provider" } - }, - "oneOf": [ - { - "required": ["all"] - }, - { - "required": ["any"] - }, - { - "required": ["not"] - }, - { - "required": ["provider"] - } - ] - }, - "provider": { - "type": "object", - "additionalProperties": false, - "required": ["provider"], - "properties": { - "provider": { "$ref": "#/$defs/id" }, - "id": { "$ref": "#/$defs/id" }, - "run": { "type": "string" }, - "report": { "type": "string" }, - "result": { "type": "object" }, - "allowed": { "$ref": "#/$defs/strings" }, - "forbidden": { "$ref": "#/$defs/strings" }, - "paths": { "$ref": "#/$defs/strings" }, - "expect": { "type": "object" }, - "assert": { "type": "object" }, - "match": { "type": "object" }, - "allow": { "type": "object" }, - "prompt": { "type": "string" }, - "working_directory": { "type": "string" }, - "inherit_environment": { "$ref": "#/$defs/strings" }, - "environment": { - "type": "object", - "additionalProperties": { "type": "string" } - }, - "timeout": { "$ref": "#/$defs/duration" }, - "retry": { "$ref": "#/$defs/retry" }, - "inputs": { "$ref": "#/$defs/strings" }, - "outputs": { "$ref": "#/$defs/strings" }, - "artifacts": { "$ref": "#/$defs/strings" }, - "depends_on": { "$ref": "#/$defs/ids" }, - "exclusive": { "type": "boolean" }, - "evidence_class": { "enum": ["deterministic", "probabilistic", "human", "informational"] }, - "configuration": { "type": "object" }, - "extra": { "type": "object" } - } - } - } -} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go deleted file mode 100644 index 4961ba1..0000000 --- a/pkg/schema/schema.go +++ /dev/null @@ -1,103 +0,0 @@ -package schema - -import ( - "bytes" - _ "embed" - "encoding/json" - "fmt" - - jsonschema "github.com/santhosh-tekuri/jsonschema/v6" -) - -//go:embed requirement.schema.json -var RequirementJSON []byte - -//go:embed evidence.schema.json -var EvidenceJSON []byte - -//go:embed verdict.schema.json -var VerdictJSON []byte - -//go:embed repair.schema.json -var RepairJSON []byte - -//go:embed ir.schema.json -var IRJSON []byte - -//go:embed report.schema.json -var ReportJSON []byte - -//go:embed plan.schema.json -var PlanJSON []byte - -var documents = map[string][]byte{ - "requirement": RequirementJSON, - "evidence": EvidenceJSON, - "verdict": VerdictJSON, - "repair": RepairJSON, - "ir": IRJSON, - "report": ReportJSON, - "plan": PlanJSON, -} - -var compiled = compileSchemas() - -// JSON returns the embedded schema with the given public name. -func JSON(name string) ([]byte, bool) { - raw, ok := documents[name] - return raw, ok -} - -// Validate checks a Go value against one of the embedded v1 schemas. -func Validate(name string, value any) error { - schema, ok := compiled[name] - if !ok { - return fmt.Errorf("unknown schema %q", name) - } - encoded, err := marshalJSON(value) - if err != nil { - return err - } - var generic any - _ = json.Unmarshal(encoded, &generic) - if err := schema.Validate(generic); err != nil { - return fmt.Errorf("%s schema validation: %w", name, err) - } - return nil -} - -func compileSchemas() map[string]*jsonschema.Schema { - compiler := jsonschema.NewCompiler() - for name, raw := range documents { - document, _ := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) - _ = compiler.AddResource(schemaURL(name), document) - } - output := make(map[string]*jsonschema.Schema, len(documents)) - for name := range documents { - output[name] = compiler.MustCompile(schemaURL(name)) - } - return output -} - -var marshalJSON = json.Marshal - -func schemaURL(name string) string { - switch name { - case "requirement": - return "https://intentci.dev/schemas/requirement-v1.json" - case "evidence": - return "https://intentci.dev/schemas/evidence-v1.json" - case "verdict": - return "https://intentci.dev/schemas/verdict-v1.json" - case "repair": - return "https://intentci.dev/schemas/repair-packet-v1.json" - case "ir": - return "https://intentci.dev/schemas/ir-v1.json" - case "report": - return "https://intentci.dev/schemas/report-v1.json" - case "plan": - return "https://intentci.dev/schemas/verification-plan-v1.json" - default: - return "" - } -} diff --git a/pkg/schema/schema_internal_test.go b/pkg/schema/schema_internal_test.go deleted file mode 100644 index e477d42..0000000 --- a/pkg/schema/schema_internal_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package schema - -import ( - "errors" - "testing" -) - -func TestSchemaLookupAndValidation(t *testing.T) { - for _, name := range []string{"requirement", "evidence", "verdict", "repair", "ir", "report", "plan"} { - if raw, ok := JSON(name); !ok || len(raw) == 0 { - t.Fatalf("%s: ok=%t len=%d", name, ok, len(raw)) - } - } - if _, ok := JSON("missing"); ok { - t.Fatal("unknown schema found") - } - if err := Validate("verdict", "pass"); err != nil { - t.Fatal(err) - } - if err := Validate("verdict", "invalid"); err == nil { - t.Fatal("invalid verdict passed") - } - if err := Validate("missing", "pass"); err == nil { - t.Fatal("unknown schema passed") - } - - old := marshalJSON - defer func() { marshalJSON = old }() - marshalJSON = func(any) ([]byte, error) { return nil, errors.New("marshal") } - if err := Validate("verdict", "pass"); err == nil { - t.Fatal("marshal error ignored") - } - if schemaURL("missing") != "" { - t.Fatal("unknown schema URL") - } -} diff --git a/pkg/schema/schema_test.go b/pkg/schema/schema_test.go deleted file mode 100644 index 71c7c78..0000000 --- a/pkg/schema/schema_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package schema_test - -import ( - "testing" - - "github.com/hypertrial/intentci/pkg/schema" -) - -func TestEmbeddedSchemasNonEmpty(t *testing.T) { - for name, b := range map[string][]byte{ - "requirement": schema.RequirementJSON, - "evidence": schema.EvidenceJSON, - "verdict": schema.VerdictJSON, - "repair": schema.RepairJSON, - "ir": schema.IRJSON, - "plan": schema.PlanJSON, - "report": schema.ReportJSON, - } { - if len(b) < 10 { - t.Fatalf("%s schema empty", name) - } - } -} diff --git a/pkg/schema/verdict.schema.json b/pkg/schema/verdict.schema.json deleted file mode 100644 index b859e3d..0000000 --- a/pkg/schema/verdict.schema.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://intentci.dev/schemas/verdict-v1.json", - "title": "IntentCI Verdict", - "type": "string", - "enum": ["pass", "fail", "unproven", "uncertain", "skipped", "review_required", "error"] -} diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh deleted file mode 100755 index 1de0e3c..0000000 --- a/scripts/check-coverage.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" - -OUT="${COVERPROFILE:-coverage.out}" -go test ./... -covermode=atomic -coverprofile="$OUT" -total="$(go tool cover -func="$OUT" | awk '/^total:/{print $3}')" -echo "total coverage: ${total}" -if [[ "$total" != "100.0%" ]]; then - echo "ERROR: expected 100.0% statement coverage, got ${total}" >&2 - go tool cover -func="$OUT" | awk '$3 != "100.0%" && $1 != "total:"' - exit 1 -fi diff --git a/scripts/check_examples.sh b/scripts/check_examples.sh deleted file mode 100755 index 6599082..0000000 --- a/scripts/check_examples.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TEMPORARY_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/intentci-examples.XXXXXX")" -trap 'rm -rf "$TEMPORARY_DIRECTORY"' EXIT - -export GOCACHE="${GOCACHE:-$TEMPORARY_DIRECTORY/go-build-cache}" - -for command_name in python3 npm cargo javac java; do - if ! command -v "$command_name" >/dev/null 2>&1; then - echo "ERROR: $command_name is required to validate the language examples" >&2 - exit 1 - fi -done - -INTENTCI_BINARY="${INTENTCI_BINARY:-$TEMPORARY_DIRECTORY/intentci}" -if [[ ! -x "$INTENTCI_BINARY" ]]; then - (cd "$REPOSITORY_ROOT" && go build -trimpath -o "$INTENTCI_BINARY" ./cmd/intentci) -fi - -(cd "$REPOSITORY_ROOT/examples/typescript" && npm ci --ignore-scripts) - -for language in go python typescript rust java; do - example="$REPOSITORY_ROOT/examples/$language" - echo "Validating $language example" - ( - cd "$example" - "$INTENTCI_BINARY" compile --strict --output "$TEMPORARY_DIRECTORY/$language-ir.json" - "$INTENTCI_BINARY" verify --all --no-git --no-cache --format json \ - --output "$TEMPORARY_DIRECTORY/$language-report.json" - ) -done - -echo "Validated Go, Python, TypeScript, Rust, and Java examples." diff --git a/scripts/check_fuzz.sh b/scripts/check_fuzz.sh deleted file mode 100755 index e7ee53c..0000000 --- a/scripts/check_fuzz.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -FUZZ_TIME="${INTENTCI_FUZZ_TIME:-2s}" - -run_fuzz() { - local package="$1" - local target="$2" - go test "$package" -run '^$' -fuzz "^${target}$" -fuzztime "$FUZZ_TIME" -} - -cd "$REPOSITORY_ROOT" -run_fuzz ./internal/impact FuzzV1PathMatching -run_fuzz ./internal/compiler FuzzV1DependencyGraphs -run_fuzz ./internal/verdict FuzzV1VerdictAggregation -run_fuzz ./internal/evidence FuzzV1ManifestHashing -run_fuzz ./internal/security FuzzV1Redaction -run_fuzz ./internal/evidence FuzzV1RunIDOrdering -run_fuzz ./internal/ir FuzzV1LogicalExpressionNormalization - -echo "IntentCI v1 property fuzz checks passed." diff --git a/scripts/check_mutation.sh b/scripts/check_mutation.sh deleted file mode 100755 index c37c155..0000000 --- a/scripts/check_mutation.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OUTPUT_DIRECTORY="${1:-$REPOSITORY_ROOT/dist/mutation}" -GREMLINS_BINARY="${GREMLINS_BINARY:-gremlins}" - -if ! command -v "$GREMLINS_BINARY" >/dev/null 2>&1; then - echo "ERROR: Gremlins v0.6.0 must be installed before running mutation checks." >&2 - echo "Install with: go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0" >&2 - exit 1 -fi - -mkdir -p "$OUTPUT_DIRECTORY" -cd "$REPOSITORY_ROOT" - -run_mutation() { - name="$1" - package="$2" - shift 2 - "$GREMLINS_BINARY" unleash "$package" \ - --workers 2 \ - --timeout-coefficient 100 \ - --threshold-efficacy 100 \ - --threshold-mcover 100 \ - --output "$OUTPUT_DIRECTORY/$name.json" \ - --output-statuses lc \ - "$@" - jq -e ' - .mutants_total > 0 and - .mutants_lived == 0 and - .mutants_not_covered == 0 and - ([.files[].mutations[] | - select(.status != "KILLED" and .status != "NOT_VIABLE")] | length) == 0 - ' "$OUTPUT_DIRECTORY/$name.json" >/dev/null -} - -run_mutation verdict ./internal/verdict -run_mutation compiler ./internal/compiler - -provider_exclusions=() -while IFS= read -r source; do - base="$(basename "$source")" - provider_exclusions+=(--exclude-files "^${base//./\\.}$") -done < <(find internal/provider -maxdepth 1 -name '*.go' ! -name '*_test.go' ! -name 'boundary.go' -print | sort) -run_mutation boundary ./internal/provider "${provider_exclusions[@]}" - -run_mutation repair ./internal/repair - -echo "Mutation checks completed with no live covered mutants." diff --git a/scripts/check_schemas.sh b/scripts/check_schemas.sh deleted file mode 100755 index dd2bf14..0000000 --- a/scripts/check_schemas.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$REPOSITORY_ROOT" - -go test ./pkg/schema - -TEMPORARY_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/intentci-schemas.XXXXXX")" -trap 'rm -rf "$TEMPORARY_DIRECTORY"' EXIT - -go build -trimpath -o "$TEMPORARY_DIRECTORY/intentci" ./cmd/intentci -for schema in requirement evidence verdict repair ir plan report; do - "$TEMPORARY_DIRECTORY/intentci" schema "$schema" > "$TEMPORARY_DIRECTORY/$schema.json" -done - -echo "Validated 7 embedded v1 JSON schemas." diff --git a/scripts/cross_compile.sh b/scripts/cross_compile.sh deleted file mode 100755 index 6863540..0000000 --- a/scripts/cross_compile.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OUTPUT_DIRECTORY="${1:-$REPOSITORY_ROOT/dist/cross-compile}" -VERSION="${INTENTCI_BUILD_VERSION:-1.1.1-dev}" - -mkdir -p "$OUTPUT_DIRECTORY" -for target in linux/amd64 darwin/amd64 darwin/arm64; do - target_os="${target%/*}" - target_arch="${target#*/}" - output="$OUTPUT_DIRECTORY/intentci_${target_os}_${target_arch}" - ( - cd "$REPOSITORY_ROOT" - CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" \ - go build -trimpath \ - -ldflags="-X github.com/hypertrial/intentci/internal/version.Version=$VERSION" \ - -o "$output" ./cmd/intentci - ) -done - -echo "Cross-compiled Linux amd64 and macOS amd64/arm64 binaries." diff --git a/scripts/package_release.sh b/scripts/package_release.sh deleted file mode 100755 index f77ae73..0000000 --- a/scripts/package_release.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="${1:?usage: package_release.sh VERSION [OUTPUT_DIRECTORY]}" -OUTPUT_DIRECTORY="${2:-$REPOSITORY_ROOT/dist/release}" -TEMPORARY_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/intentci-package.XXXXXX")" -trap 'rm -rf "$TEMPORARY_DIRECTORY"' EXIT - -mkdir -p "$OUTPUT_DIRECTORY" -for target in linux/amd64 darwin/amd64 darwin/arm64; do - target_os="${target%/*}" - target_arch="${target#*/}" - stage="$TEMPORARY_DIRECTORY/${target_os}_${target_arch}" - mkdir -p "$stage" - ( - cd "$REPOSITORY_ROOT" - CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" \ - go build -trimpath \ - -ldflags="-s -w -X github.com/hypertrial/intentci/internal/version.Version=$VERSION" \ - -o "$stage/intentci" ./cmd/intentci - ) - archive="$OUTPUT_DIRECTORY/intentci_${VERSION}_${target_os}_${target_arch}.tar.gz" - tar --sort=name --owner=0 --group=0 --numeric-owner \ - --mtime='UTC 1970-01-01' -cf - -C "$stage" intentci | gzip -n > "$archive" -done - -( - cd "$OUTPUT_DIRECTORY" - sha256sum intentci_"$VERSION"_*.tar.gz > checksums.txt -) - -echo "Created deterministic v$VERSION release archives and checksums." diff --git a/scripts/record_performance.sh b/scripts/record_performance.sh deleted file mode 100755 index b3ae214..0000000 --- a/scripts/record_performance.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OUTPUT_PATH="${1:-$REPOSITORY_ROOT/dist/performance.txt}" -TEMPORARY_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/intentci-performance.XXXXXX")" -trap 'rm -rf "$TEMPORARY_DIRECTORY"' EXIT - -mkdir -p "$(dirname "$OUTPUT_PATH")" -( - cd "$REPOSITORY_ROOT" - go build -trimpath -o "$TEMPORARY_DIRECTORY/intentci" ./cmd/intentci - TIME_OUTPUT="$TEMPORARY_DIRECTORY/time.txt" - if [[ "$(uname -s)" == "Darwin" ]]; then - /usr/bin/time -l "$TEMPORARY_DIRECTORY/intentci" version > /dev/null 2> "$TIME_OUTPUT" - PEAK_RSS_BYTES="$(awk '/maximum resident set size/ { print $1; exit }' "$TIME_OUTPUT")" - else - /usr/bin/time -v "$TEMPORARY_DIRECTORY/intentci" version > /dev/null 2> "$TIME_OUTPUT" - PEAK_RSS_KIB="$(awk -F: '/Maximum resident set size/ { gsub(/ /, "", $2); print $2; exit }' "$TIME_OUTPUT")" - PEAK_RSS_BYTES="$((PEAK_RSS_KIB * 1024))" - fi - if [[ ! "$PEAK_RSS_BYTES" =~ ^[0-9]+$ ]]; then - echo "ERROR: unable to measure peak resident memory" >&2 - exit 1 - fi - { - echo "IntentCI v1 performance record" - echo "generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "platform=$(go env GOOS)/$(go env GOARCH)" - echo "go_version=$(go version)" - echo "commit=$(git rev-parse HEAD)" - echo "binary_bytes=$(wc -c < "$TEMPORARY_DIRECTORY/intentci" | tr -d ' ')" - echo "version_peak_rss_bytes=$PEAK_RSS_BYTES" - echo - INTENTCI_BENCHMARK_BINARY="$TEMPORARY_DIRECTORY/intentci" \ - go test ./internal/compiler ./internal/impact ./internal/verdict ./internal/executor \ - ./tests/performance -run '^$' -bench '^BenchmarkV1' -benchmem -count=5 - } > "$OUTPUT_PATH" -) - -cat "$OUTPUT_PATH" diff --git a/scripts/validate_v1_release.sh b/scripts/validate_v1_release.sh deleted file mode 100755 index 276178a..0000000 --- a/scripts/validate_v1_release.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -OUTPUT_DIRECTORY="${1:-$REPOSITORY_ROOT/dist/release-evidence}" -if [[ "$OUTPUT_DIRECTORY" != /* ]]; then - OUTPUT_DIRECTORY="$REPOSITORY_ROOT/$OUTPUT_DIRECTORY" -fi -TEMPORARY_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/intentci-release.XXXXXX")" -trap 'rm -rf "$TEMPORARY_DIRECTORY"' EXIT - -mkdir -p "$OUTPUT_DIRECTORY" -export GOCACHE="${GOCACHE:-$TEMPORARY_DIRECTORY/go-build-cache}" - -cd "$REPOSITORY_ROOT" -go vet ./... -go test -race ./... -./scripts/check-coverage.sh -./scripts/check_fuzz.sh -./scripts/check_schemas.sh -./scripts/check_examples.sh -INTENTCI_ACCEPTANCE_OUTPUT="$OUTPUT_DIRECTORY/acceptance-v1.json" \ - go test ./tests/acceptance -run '^TestV1Acceptance$' -v -INTENTCI_BUILD_VERSION="${INTENTCI_BUILD_VERSION:-1.1.1-dev}" \ - ./scripts/cross_compile.sh "$TEMPORARY_DIRECTORY/cross-compile" -./scripts/record_performance.sh "$OUTPUT_DIRECTORY/performance.txt" - -if ! grep -q '"all_passed": true' "$OUTPUT_DIRECTORY/acceptance-v1.json"; then - echo "ERROR: v1 acceptance matrix is not fully green" >&2 - exit 1 -fi - -if [[ "${INTENTCI_RUN_MUTATION:-0}" == "1" ]]; then - ./scripts/check_mutation.sh "$OUTPUT_DIRECTORY/mutation" -fi - -echo "IntentCI v1 release validation passed." diff --git a/tests/acceptance/v1_acceptance_test.go b/tests/acceptance/v1_acceptance_test.go deleted file mode 100644 index d17d586..0000000 --- a/tests/acceptance/v1_acceptance_test.go +++ /dev/null @@ -1,576 +0,0 @@ -package acceptance_test - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/hypertrial/intentci/internal/config" - "github.com/hypertrial/intentci/internal/evidence" - "github.com/hypertrial/intentci/internal/impact" - "github.com/hypertrial/intentci/internal/ir" - "github.com/hypertrial/intentci/internal/provider" - "github.com/hypertrial/intentci/internal/verdict" -) - -var repositoryRoot string -var intentciBinary string - -func TestMain(m *testing.M) { - _, file, _, _ := runtime.Caller(0) - repositoryRoot = filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) - buildDir, err := os.MkdirTemp("", "intentci-acceptance-*") - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - intentciBinary = filepath.Join(buildDir, "intentci") - command := exec.Command("go", "build", "-trimpath", "-o", intentciBinary, "./cmd/intentci") - command.Dir = repositoryRoot - command.Stdout = os.Stdout - command.Stderr = os.Stderr - if err := command.Run(); err != nil { - _ = os.RemoveAll(buildDir) - os.Exit(1) - } - code := m.Run() - _ = os.RemoveAll(buildDir) - os.Exit(code) -} - -type acceptanceResult struct { - ID string `json:"id"` - Description string `json:"description"` - Status string `json:"status"` - Test string `json:"test"` -} - -type acceptanceMatrix struct { - SchemaVersion string `json:"schema_version"` - Specification string `json:"specification"` - GeneratedAt time.Time `json:"generated_at"` - Commit string `json:"commit,omitempty"` - Platform string `json:"platform"` - AllPassed bool `json:"all_passed"` - Criteria []acceptanceResult `json:"criteria"` -} - -type acceptanceSuite struct { - workspace string - passingRepository string - passingBundle *evidence.Bundle - repairRepository string - repairBundle *evidence.Bundle -} - -func TestV1Acceptance(t *testing.T) { - workspace, err := os.MkdirTemp("", "intentci-v1-suite-*") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.RemoveAll(workspace) }) - suite := &acceptanceSuite{workspace: workspace} - cases := []struct { - id string - description string - run func(*testing.T, *acceptanceSuite) - }{ - {"AC-01", "A user can initialize IntentCI in an existing repository.", acceptInitialize}, - {"AC-02", "Requirements can be authored in human-readable Markdown.", acceptMarkdown}, - {"AC-03", "Requirement files compile into stable canonical JSON.", acceptCanonicalCompilation}, - {"AC-04", "Invalid requirement graphs fail with actionable diagnostics.", acceptInvalidGraph}, - {"AC-05", "Existing test commands can verify obligations.", acceptCommandVerification}, - {"AC-06", "JUnit and SARIF reports can be mapped to obligations.", acceptReportProviders}, - {"AC-07", "File-boundary violations are detected.", acceptBoundaryViolation}, - {"AC-08", "Changed files can select affected requirements.", acceptChangedSelection}, - {"AC-09", "Evidence is tied to repository state and contract hashes.", acceptEvidenceProvenance}, - {"AC-10", "Every required obligation receives an explicit verdict.", acceptExplicitVerdicts}, - {"AC-11", "Missing evidence cannot produce a passing requirement.", acceptMissingEvidence}, - {"AC-12", "A failed requirement produces a structured repair packet.", acceptRepairPacket}, - {"AC-13", "An external coding agent can be invoked for bounded repair attempts.", acceptRepairAgent}, - {"AC-14", "The agent cannot silently modify protected contracts.", acceptProtectedContract}, - {"AC-15", "Repeated ineffective attempts are stopped.", acceptRepeatedFailure}, - {"AC-16", "Terminal, JSON, and JUnit reports are generated.", acceptReports}, - {"AC-17", "GitHub Actions can use the CLI without a custom service.", acceptGitHubActions}, - {"AC-18", "Linux and macOS are supported.", acceptPlatforms}, - {"AC-19", "No telemetry is sent by default.", acceptTelemetryDefault}, - {"AC-20", "The complete end-to-end workflow is covered by automated tests.", acceptCompleteWorkflow}, - } - matrix := acceptanceMatrix{ - SchemaVersion: "1.0", Specification: "v1.md#38", GeneratedAt: time.Now().UTC(), - Commit: gitOutput(repositoryRoot, "rev-parse", "HEAD"), Platform: runtime.GOOS + "/" + runtime.GOARCH, - AllPassed: true, - } - defer func() { - path := os.Getenv("INTENTCI_ACCEPTANCE_OUTPUT") - if path == "" { - return - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Errorf("create acceptance output directory: %v", err) - return - } - raw, err := json.MarshalIndent(matrix, "", " ") - if err != nil { - t.Errorf("marshal acceptance matrix: %v", err) - return - } - if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil { - t.Errorf("write acceptance matrix: %v", err) - } - }() - for _, item := range cases { - item := item - passed := t.Run(item.id, func(t *testing.T) { - item.run(t, suite) - }) - status := "passed" - if !passed { - status = "failed" - matrix.AllPassed = false - } - matrix.Criteria = append(matrix.Criteria, acceptanceResult{ - ID: item.id, Description: item.description, Status: status, - Test: "tests/acceptance/v1_acceptance_test.go::TestV1Acceptance/" + item.id, - }) - } -} - -func acceptInitialize(t *testing.T, suite *acceptanceSuite) { - root := filepath.Join(suite.workspace, "passing") - if err := os.MkdirAll(root, 0o755); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(root, "go.mod"), "module example.com/acceptance\n\ngo 1.23\n") - writeFile(t, filepath.Join(root, "calculator.go"), "package calculator\n\nfunc Add(a, b int) int { return a + b }\n") - writeFile(t, filepath.Join(root, "calculator_test.go"), `package calculator - -import "testing" - -func TestAdd(t *testing.T) { - if Add(2, 3) != 5 { - t.Fatal("wrong sum") - } -} -`) - runCLI(t, root, 0, "init", "--language", "go") - for _, relative := range []string{ - ".intentci/config.yaml", - ".intentci/requirements/REQ-001.md", - } { - if _, err := os.Stat(filepath.Join(root, relative)); err != nil { - t.Fatalf("%s: %v", relative, err) - } - } - initializeGit(t, root) - suite.passingRepository = root -} - -func acceptMarkdown(t *testing.T, suite *acceptanceSuite) { - raw := readFile(t, filepath.Join(suite.passingRepository, ".intentci", "requirements", "REQ-001.md")) - for _, expected := range []string{"---", "# Intent", "# Obligations", "```yaml"} { - if !strings.Contains(string(raw), expected) { - t.Fatalf("generated requirement missing %q", expected) - } - } -} - -func acceptCanonicalCompilation(t *testing.T, suite *acceptanceSuite) { - first := filepath.Join(t.TempDir(), "first.json") - second := filepath.Join(t.TempDir(), "second.json") - runCLI(t, suite.passingRepository, 0, "compile", "--strict", "--output", first) - runCLI(t, suite.passingRepository, 0, "compile", "--strict", "--output", second) - firstRaw, secondRaw := readFile(t, first), readFile(t, second) - if string(firstRaw) != string(secondRaw) { - t.Fatal("repeated compilation was not byte-identical") - } - var document ir.Document - if err := json.Unmarshal(firstRaw, &document); err != nil || document.Hash == "" { - t.Fatalf("canonical IR is invalid: hash=%q err=%v", document.Hash, err) - } -} - -func acceptInvalidGraph(t *testing.T, _ *acceptanceSuite) { - root := t.TempDir() - runCLI(t, root, 0, "init") - path := filepath.Join(root, ".intentci", "requirements", "REQ-001.md") - body := string(readFile(t, path)) - body = strings.Replace(body, "depends_on: []", "depends_on:\n - REQ-MISSING", 1) - writeFile(t, path, body) - _, stderr := runCLI(t, root, 5, "compile", "--strict") - if !strings.Contains(stderr, "REQ-MISSING") || - !strings.Contains(stderr, ".intentci/requirements/REQ-001.md") { - t.Fatalf("diagnostic is not actionable:\n%s", stderr) - } -} - -func acceptCommandVerification(t *testing.T, suite *acceptanceSuite) { - reportPath := filepath.Join(t.TempDir(), "report.json") - runCLI(t, suite.passingRepository, 0, - "verify", "--all", "--base", "HEAD", "--no-cache", "--format", "json", "--output", reportPath) - var bundle evidence.Bundle - if err := json.Unmarshal(readFile(t, reportPath), &bundle); err != nil { - t.Fatal(err) - } - if bundle.Run.Verdict != verdict.Pass { - t.Fatalf("run verdict = %s", bundle.Run.Verdict) - } - store, err := evidence.NewStore(suite.passingRepository, ".intentci/runs") - if err != nil { - t.Fatal(err) - } - suite.passingBundle, err = store.LoadLatest() - if err != nil { - t.Fatal(err) - } - runCLI(t, suite.passingRepository, 0, "explain", "REQ-001", "--show-evidence") -} - -func acceptReportProviders(t *testing.T, _ *acceptanceSuite) { - root := t.TempDir() - copyFile(t, filepath.Join(repositoryRoot, "fixtures", "reports", "junit-failure.xml"), filepath.Join(root, "junit.xml")) - copyFile(t, filepath.Join(repositoryRoot, "fixtures", "reports", "sarif-error.json"), filepath.Join(root, "sarif.json")) - junit := (&provider.JUnitProvider{}).Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{ID: "junit", Report: "junit.xml"}, - }) - sarif := (&provider.SARIFProvider{}).Execute(context.Background(), provider.Request{ - Root: root, Spec: ir.ProviderSpec{ - ID: "sarif", Report: "sarif.json", Allow: map[string]any{"max_findings": 0}, - }, - }) - for name, result := range map[string]provider.Result{"junit": junit, "sarif": sarif} { - if result.Status != "completed" || len(result.Evidence) != 1 || - result.Evidence[0].Passed == nil || *result.Evidence[0].Passed { - t.Fatalf("%s did not map a failing report: %#v", name, result) - } - } -} - -func acceptBoundaryViolation(t *testing.T, _ *acceptanceSuite) { - result := (&provider.BoundaryProvider{}).Execute(context.Background(), provider.Request{ - ChangedFiles: []string{"src/allowed.go", "secrets/token.txt"}, - Spec: ir.ProviderSpec{ - ID: "boundary", Allowed: []string{"src/**"}, Forbidden: []string{"secrets/**"}, - }, - }) - if !result.SecurityViolation || len(result.Evidence) != 1 || - result.Evidence[0].Passed == nil || *result.Evidence[0].Passed { - t.Fatalf("boundary violation was not detected: %#v", result) - } -} - -func acceptChangedSelection(t *testing.T, _ *acceptanceSuite) { - document := &ir.Document{Requirements: []ir.Requirement{ - {ID: "REQ-A", Status: "active", AppliesTo: ir.AppliesTo{Paths: []string{"src/a/**"}}}, - {ID: "REQ-B", Status: "active", AppliesTo: ir.AppliesTo{Paths: []string{"src/b/**"}}}, - }} - selection := impact.Select(document, impact.Options{ChangedFiles: []string{"src/a/file.go"}}) - if len(selection.Requirements) != 1 || selection.Requirements[0].ID != "REQ-A" { - t.Fatalf("unexpected impact selection: %#v", selection) - } -} - -func acceptEvidenceProvenance(t *testing.T, suite *acceptanceSuite) { - bundle := suite.passingBundle - if bundle == nil { - t.Fatal("passing verification bundle is unavailable") - } - if bundle.IRHash == "" || bundle.HeadCommit == "" || - bundle.RepositoryState == nil || bundle.RepositoryState.DiffHash == "" { - t.Fatalf("bundle provenance incomplete: %#v", bundle) - } - for _, result := range bundle.ProviderLogs { - for _, record := range result.Evidence { - if record.RequirementHash == "" || record.ObligationHash == "" || - record.PlanHash == "" || record.RepositoryCommit == "" { - t.Fatalf("evidence provenance incomplete: %#v", record) - } - } - } -} - -func acceptExplicitVerdicts(t *testing.T, suite *acceptanceSuite) { - if suite.passingBundle == nil { - t.Fatal("passing verification bundle is unavailable") - } - for _, requirement := range suite.passingBundle.Run.Requirements { - for _, obligation := range requirement.Obligations { - if obligation.Required && obligation.Verdict == "" { - t.Fatalf("%s/%s has no verdict", requirement.ID, obligation.ID) - } - } - } -} - -func acceptMissingEvidence(t *testing.T, _ *acceptanceSuite) { - node := ir.VerifyNode{Provider: &ir.ProviderSpec{Provider: "external", ID: "missing"}} - got, _, _ := verdict.EvaluateNode(node, nil) - if got == verdict.Pass { - t.Fatal("missing provider evidence produced pass") - } -} - -func acceptRepairPacket(t *testing.T, suite *acceptanceSuite) { - root := copyRepairFixture(t) - _, stderr := runCLI(t, root, 9, "repair", "--dry-run", "--max-attempts", "1") - if !strings.Contains(stderr, "max_attempts") { - t.Fatalf("repair stop reason missing: %s", stderr) - } - store, err := evidence.NewStore(root, ".intentci/runs") - if err != nil { - t.Fatal(err) - } - runID := strings.TrimSpace(string(readFile(t, filepath.Join(store.Root, "latest")))) - packetPath := filepath.Join(store.Dir(runID), "attempts", "attempt-001", "repair-packet.json") - var packet map[string]any - if err := json.Unmarshal(readFile(t, packetPath), &packet); err != nil { - t.Fatal(err) - } - if packet["run_id"] != runID || len(packet["failures"].([]any)) == 0 { - t.Fatalf("repair packet is incomplete: %#v", packet) - } - suite.repairRepository = root -} - -func acceptRepairAgent(t *testing.T, suite *acceptanceSuite) { - root := filepath.Join(suite.workspace, "repair-success") - copyTree(t, filepath.Join(repositoryRoot, "fixtures", "repair-go"), root) - initializeGit(t, root) - runCLI(t, root, 0, - "repair", "--agent-command", "sh repair/fake-agent.sh {packet}", "--max-attempts", "2") - store, err := evidence.NewStore(root, ".intentci/runs") - if err != nil { - t.Fatal(err) - } - suite.repairBundle, err = store.LoadLatest() - if err != nil { - t.Fatal(err) - } - runCLI(t, root, 0, "verify", "--all", "--base", "HEAD", "--no-cache") - suite.repairRepository = root - if suite.repairBundle.Run.Verdict != verdict.Pass { - t.Fatalf("repaired verdict = %s", suite.repairBundle.Run.Verdict) - } -} - -func acceptProtectedContract(t *testing.T, _ *acceptanceSuite) { - root := copyRepairFixture(t) - command := "printf '\\nmalicious contract edit\\n' >> .intentci/requirements/REQ-REPAIR-001.md" - _, stderr := runCLI(t, root, 10, "repair", "--agent-command", command, "--max-attempts", "2") - if !strings.Contains(stderr, "protected_path") { - t.Fatalf("protected-path stop reason missing: %s", stderr) - } -} - -func acceptRepeatedFailure(t *testing.T, _ *acceptanceSuite) { - root := copyRepairFixture(t) - _, stderr := runCLI(t, root, 9, "repair", "--agent-command", "true", "--max-attempts", "3") - if !strings.Contains(stderr, "repeated_failure") { - t.Fatalf("repeated-failure stop reason missing: %s", stderr) - } -} - -func acceptReports(t *testing.T, suite *acceptanceSuite) { - if suite.repairBundle == nil { - t.Fatal("successful repair bundle is unavailable") - } - runDir := filepath.Join(suite.repairRepository, ".intentci", "runs", suite.repairBundle.RunID) - for _, relative := range []string{"report.txt", "report.json", "report.junit.xml"} { - if info, err := os.Stat(filepath.Join(runDir, relative)); err != nil || info.Size() == 0 { - t.Fatalf("%s: size=%v err=%v", relative, sizeOf(info), err) - } - } -} - -func acceptGitHubActions(t *testing.T, _ *acceptanceSuite) { - raw := string(readFile(t, filepath.Join(repositoryRoot, "examples", "github-actions", "intentci.yml"))) - if !strings.Contains(raw, "intentci verify") || strings.Contains(raw, "curl ") { - t.Fatalf("workflow does not use the standalone CLI:\n%s", raw) - } -} - -func acceptPlatforms(t *testing.T, _ *acceptanceSuite) { - for _, target := range []string{"linux/amd64", "darwin/amd64", "darwin/arm64"} { - parts := strings.Split(target, "/") - output := filepath.Join(t.TempDir(), "intentci-"+parts[0]+"-"+parts[1]) - command := exec.Command("go", "build", "-trimpath", "-o", output, "./cmd/intentci") - command.Dir = repositoryRoot - command.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS="+parts[0], "GOARCH="+parts[1]) - if raw, err := command.CombinedOutput(); err != nil { - t.Fatalf("%s: %v\n%s", target, err, raw) - } - } -} - -func acceptTelemetryDefault(t *testing.T, _ *acceptanceSuite) { - if config.Default().Telemetry.Enabled { - t.Fatal("telemetry is enabled by default") - } -} - -func acceptCompleteWorkflow(t *testing.T, suite *acceptanceSuite) { - if suite.passingBundle == nil || suite.repairBundle == nil { - t.Fatal("init/compile/verify/explain or fail/repair/pass workflow did not complete") - } - runDir := filepath.Join(suite.repairRepository, ".intentci", "runs", suite.repairBundle.RunID) - for _, relative := range []string{ - "compiled-intent.json", "verification-plan.json", "repository-state.json", "diff.patch", - "attempts/attempt-001/evidence.json", "attempts/attempt-001/verdict.json", - "attempts/attempt-002/evidence.json", "attempts/attempt-002/verdict.json", - "manifest.json", "final-verdict.json", - } { - if _, err := os.Stat(filepath.Join(runDir, relative)); err != nil { - t.Fatalf("%s: %v", relative, err) - } - } - var manifest evidence.Manifest - manifestRaw := readFile(t, filepath.Join(runDir, "manifest.json")) - if err := json.Unmarshal(manifestRaw, &manifest); err != nil { - t.Fatal(err) - } - for _, artifact := range manifest.Artifacts { - raw := readFile(t, filepath.Join(runDir, filepath.FromSlash(artifact.Path))) - sum := sha256.Sum256(raw) - if artifact.SHA256 != hex.EncodeToString(sum[:]) { - t.Fatalf("manifest hash mismatch for %s", artifact.Path) - } - } - var final evidence.FinalVerdict - if err := json.Unmarshal(readFile(t, filepath.Join(runDir, "final-verdict.json")), &final); err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(manifestRaw) - if final.ManifestHash != hex.EncodeToString(sum[:]) { - t.Fatal("final verdict does not reference the manifest hash") - } -} - -func runCLI(t *testing.T, root string, wantCode int, arguments ...string) (string, string) { - t.Helper() - command := exec.Command(intentciBinary, arguments...) - command.Dir = root - command.Env = os.Environ() - var stdout, stderr strings.Builder - command.Stdout = &stdout - command.Stderr = &stderr - err := command.Run() - code := 0 - if err != nil { - var exitError *exec.ExitError - if !errors.As(err, &exitError) { - t.Fatalf("intentci %v: %v", arguments, err) - } - code = exitError.ExitCode() - } - if code != wantCode { - t.Fatalf("intentci %v returned %d, want %d\nstdout:\n%s\nstderr:\n%s", - arguments, code, wantCode, stdout.String(), stderr.String()) - } - return stdout.String(), stderr.String() -} - -func copyRepairFixture(t *testing.T) string { - t.Helper() - root := t.TempDir() - copyTree(t, filepath.Join(repositoryRoot, "fixtures", "repair-go"), root) - initializeGit(t, root) - return root -} - -func initializeGit(t *testing.T, root string) { - t.Helper() - for _, arguments := range [][]string{ - {"init"}, - {"config", "user.email", "acceptance@intentci.test"}, - {"config", "user.name", "IntentCI Acceptance"}, - {"add", "."}, - {"commit", "-m", "fixture"}, - } { - command := exec.Command("git", arguments...) - command.Dir = root - if raw, err := command.CombinedOutput(); err != nil { - t.Fatalf("git %v: %v\n%s", arguments, err, raw) - } - } -} - -func copyTree(t *testing.T, source, target string) { - t.Helper() - err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - relative, err := filepath.Rel(source, path) - if err != nil { - return err - } - destination := filepath.Join(target, relative) - if entry.IsDir() { - return os.MkdirAll(destination, 0o755) - } - info, err := entry.Info() - if err != nil { - return err - } - raw, err := os.ReadFile(path) - if err != nil { - return err - } - return os.WriteFile(destination, raw, info.Mode().Perm()) - }) - if err != nil { - t.Fatal(err) - } -} - -func copyFile(t *testing.T, source, target string) { - t.Helper() - raw := readFile(t, source) - if err := os.WriteFile(target, raw, 0o644); err != nil { - t.Fatal(err) - } -} - -func writeFile(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func readFile(t *testing.T, path string) []byte { - t.Helper() - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return raw -} - -func gitOutput(root string, arguments ...string) string { - command := exec.Command("git", arguments...) - command.Dir = root - raw, _ := command.Output() - return strings.TrimSpace(string(raw)) -} - -func sizeOf(info os.FileInfo) int64 { - if info == nil { - return 0 - } - return info.Size() -} diff --git a/tests/performance/performance_test.go b/tests/performance/performance_test.go deleted file mode 100644 index 4ac6215..0000000 --- a/tests/performance/performance_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package performance_test - -import ( - "os" - "os/exec" - "testing" -) - -func BenchmarkV1CLIStartup(b *testing.B) { - binary := os.Getenv("INTENTCI_BENCHMARK_BINARY") - if binary == "" { - b.Skip("INTENTCI_BENCHMARK_BINARY is not set") - } - b.ResetTimer() - for iteration := 0; iteration < b.N; iteration++ { - if err := exec.Command(binary, "version").Run(); err != nil { - b.Fatal(err) - } - } -} diff --git a/v1.md b/v1.md deleted file mode 100644 index 30f28ec..0000000 --- a/v1.md +++ /dev/null @@ -1,2878 +0,0 @@ -# IntentCI v1 Product Specification - -**Status:** Proposed -**Version:** 1.0 -**Product type:** Open-source CLI and verification protocol -**Primary implementation language:** Go -**License:** Apache-2.0 -**Primary interface:** Local CLI -**Primary operating mode:** Repository-local, deterministic, CI-compatible - ---- - -## 1. Executive Summary - -IntentCI is a language-agnostic verification system for agent-generated code. - -It connects human intent to machine-verifiable evidence: - -```text -Human intent - ↓ -Structured requirements - ↓ -Verification obligations - ↓ -Independent checks - ↓ -Evidence - ↓ -Requirement verdicts - ↓ -Agent repair instructions -``` - -IntentCI does not replace test frameworks, static analyzers, policy engines, CI systems, or coding agents. It coordinates them through a common semantic model. - -IntentCI owns: - -* the requirement and obligation model; -* verification planning; -* evidence normalization; -* requirement-to-check traceability; -* verdict semantics; -* bounded agent repair loops; -* change-impact selection. - -Existing tools continue to perform the underlying checks: - -* pytest, Jest, Go test, Cargo, Maven, and similar test runners; -* Semgrep, CodeQL, linters, and type checkers; -* OPA and other policy engines; -* SARIF and JUnit producers; -* GitHub Actions and other CI systems; -* Codex, Claude Code, Aider, Cursor, and arbitrary agent commands. - -The central product claim is: - -> IntentCI determines whether the implementation satisfies the declared intent, explains what is proven or unproven, and gives coding agents structured evidence for repair. - ---- - -# 2. Problem - -Coding agents can produce code quickly, but repositories generally lack a formal connection between: - -* the requested product behavior; -* architectural constraints; -* implementation boundaries; -* tests and static checks; -* generated changes; -* verification evidence; -* final acceptance. - -Most current workflows rely on loosely connected artifacts: - -```text -Issue -→ agent prompt -→ code changes -→ tests -→ pull request -→ human interpretation -``` - -This creates several failure modes: - -1. Tests pass, but they do not cover the actual requirement. -2. The agent changes files or interfaces outside the requested scope. -3. The agent writes weak tests that merely validate its own implementation. -4. A requirement is partially satisfied but reported as complete. -5. Different verification tools produce results that cannot be aggregated. -6. Failed checks are returned to the agent as raw logs without semantic context. -7. There is no durable record of what evidence justified acceptance. -8. Changes invalidate earlier evidence without being detected. -9. Subjective or non-deterministic checks are treated as equivalent to deterministic proof. -10. CI answers whether commands passed, not whether the requested outcome was delivered. - ---- - -# 3. Product Vision - -IntentCI becomes the standard interface between: - -```text -What humans asked for -``` - -and: - -```text -What machines can demonstrate was delivered -``` - -IntentCI should eventually support the full software-development lifecycle: - -```text -Requirement -→ plan -→ implementation -→ verification -→ repair -→ acceptance -→ regression protection -``` - -Version 1 focuses only on the verification and repair portion. - ---- - -# 4. Product Principles - -## 4.1 Own semantics, outsource mechanics - -IntentCI owns the definition of requirements, obligations, evidence, and verdicts. - -IntentCI does not reimplement mature tools that already execute tests, analyze code, enforce policies, or run CI jobs. - -## 4.2 Deterministic by default - -The core compiler and verdict engine must not require an LLM. - -The same repository state, configuration, and evidence must produce the same verdict. - -LLM-based evaluation may be used through an explicit provider, but its evidence must be identified as non-deterministic. - -## 4.3 Evidence before confidence - -A requirement is not complete because an agent claims it is complete. - -Each requirement must be supported by explicit evidence or classified as unproven. - -## 4.4 Human-readable and machine-structured - -Requirements must remain reviewable in Markdown while containing enough structure for deterministic compilation. - -## 4.5 Existing repositories first - -IntentCI must work in repositories that already use their own: - -* languages; -* tests; -* CI systems; -* linters; -* build commands; -* coding agents. - -Adoption must not require replacing the existing toolchain. - -## 4.6 Local-first - -All core functionality must work locally without a hosted service. - -No repository data or telemetry leaves the machine unless explicitly configured. - -## 4.7 Agent-neutral - -IntentCI must not depend on one model, vendor, editor, or coding-agent framework. - -## 4.8 Failure must be precise - -A failed verification must identify: - -* which requirement failed; -* which obligation failed; -* which verifier produced the failure; -* what evidence was observed; -* what the agent is allowed to change; -* what must remain unchanged. - -## 4.9 Passing tests are not universal proof - -IntentCI must distinguish among: - -* verified; -* violated; -* unproven; -* uncertain; -* skipped; -* requiring human review. - -## 4.10 Contracts are immutable during an attempt - -An implementation agent may not silently weaken requirements or modify required checks during a repair attempt. - ---- - -# 5. Goals - -IntentCI v1 must: - -1. Let repository owners express intent in a structured, human-readable format. -2. Compile requirements into explicit verification obligations. -3. Execute existing repository checks through providers. -4. Normalize results into a common evidence model. -5. Connect every evidence item to one or more obligations. -6. produce requirement-level and run-level verdicts. -7. Detect forbidden file and dependency changes. -8. Select affected requirements from a Git diff. -9. generate machine-readable and human-readable reports. -10. support bounded coding-agent repair loops. -11. work locally and in GitHub Actions. -12. preserve immutable evidence for each attempt. -13. support any language through shell commands. -14. expose stable JSON schemas for integrations. -15. produce useful results without a hosted service or database server. - ---- - -# 6. Non-Goals - -IntentCI v1 will not: - -* generate complete requirements automatically from arbitrary prose; -* replace pytest, Jest, Go test, Cargo, or similar test runners; -* replace OPA, Semgrep, CodeQL, or existing security tools; -* provide a graphical workflow editor; -* provide hosted CI infrastructure; -* provide a hosted observability platform; -* provide a custom programming language; -* perform general-purpose multi-agent orchestration; -* guarantee that tests themselves are correct; -* prove arbitrary semantic properties of code; -* automatically infer complete requirement-to-code mappings; -* provide distributed workflow execution; -* provide enterprise identity, billing, or access control; -* edit pull requests directly; -* merge code automatically; -* require Temporal, LangGraph, Phoenix, or Langfuse; -* provide a universal autonomous software engineer. - ---- - -# 7. Target Users - -## 7.1 Primary users - -### Repository maintainers - -Maintainers who want agent-generated changes to remain within declared behavioral and architectural boundaries. - -### Developers using coding agents - -Developers who want structured feedback rather than repeatedly pasting raw CI logs into an agent. - -### Platform and developer-experience teams - -Teams that want a standard requirement-verification protocol across repositories and languages. - -### Quality and security engineers - -Engineers who need traceability from requirements to tests, policies, and security findings. - -## 7.2 Secondary users - -* open-source maintainers; -* regulated software teams; -* AI application developers; -* data-platform teams; -* internal tools teams; -* agent-framework developers; -* CI vendors. - ---- - -# 8. Core Use Cases - -## 8.1 Verify a human-authored requirement - -A maintainer defines a requirement and maps its obligations to existing test and policy commands. - -IntentCI runs the checks and reports whether the requirement is proven. - -## 8.2 Verify an agent-generated patch - -A coding agent modifies the repository. - -IntentCI identifies affected requirements, executes their obligations, checks boundaries, and creates a verdict. - -## 8.3 Repair a failed implementation - -IntentCI provides a coding agent with a structured failure packet. - -The agent modifies the implementation and IntentCI reruns only the invalidated checks. - -## 8.4 Gate a pull request - -GitHub Actions executes IntentCI and publishes: - -* a pull-request summary; -* requirement-level statuses; -* JUnit output; -* SARIF findings; -* a JSON evidence bundle; -* the final exit status. - -## 8.5 Explain why a requirement passed or failed - -A developer can inspect: - -```bash -intentci explain REQ-AUTH-001 -``` - -IntentCI shows the complete chain: - -```text -Requirement -→ obligations -→ verifiers -→ evidence -→ verdict -``` - -## 8.6 Detect unauthorized changes - -A requirement may allow changes to `src/auth/**` while forbidding changes to: - -```text -migrations/** -src/billing/** -public-api/** -``` - -IntentCI fails the attempt if the patch crosses those boundaries. - ---- - -# 9. Conceptual Model - -IntentCI v1 has seven primary entities. - -## 9.1 Requirement - -A human-meaningful desired outcome. - -Example: - -```text -Existing customers can authenticate using their current credentials. -``` - -## 9.2 Obligation - -A specific statement that must be verified for the requirement to pass. - -Examples: - -* valid credentials create a session; -* invalid credentials do not create a session; -* existing registration behavior remains unchanged; -* no database migration is introduced; -* passwords never appear in logs. - -## 9.3 Verifier - -A configured mechanism that evaluates an obligation. - -Examples: - -* a pytest test; -* a shell command; -* a Semgrep rule; -* a file-boundary check; -* a SARIF result filter; -* a human-review request; -* an LLM evaluator. - -## 9.4 Evidence - -An immutable observation produced by a verifier. - -Examples: - -* command exit code; -* JUnit test result; -* SARIF finding; -* changed-file list; -* policy decision; -* coverage report; -* evaluator score; -* human approval. - -## 9.5 Verdict - -IntentCI’s interpretation of the evidence relative to an obligation or requirement. - -## 9.6 Attempt - -One immutable implementation-and-verification cycle. - -## 9.7 Repair packet - -A structured description of failed or unresolved obligations supplied to a coding agent. - ---- - -# 10. Verdict Semantics - -## 10.1 Obligation verdicts - -Every obligation receives one of these verdicts: - -| Verdict | Meaning | -| ----------------- | -------------------------------------------------------------------------------------------------- | -| `pass` | Required evidence demonstrates that the obligation is satisfied. | -| `fail` | Evidence demonstrates that the obligation is violated. | -| `unproven` | Required evidence is missing, incomplete, or not executed. | -| `uncertain` | Evidence exists but is non-deterministic, conflicting, or below the required confidence threshold. | -| `skipped` | The obligation was intentionally excluded under an explicit rule. | -| `review_required` | A human decision is required. | -| `error` | IntentCI or the verifier could not complete reliably. | - -## 10.2 Requirement verdicts - -The default requirement aggregation rules are: - -```text -Any required obligation = fail - → requirement = fail - -Any required obligation = error - → requirement = error - -No failures, but one or more required obligations = review_required - → requirement = review_required - -No failures, but one or more required obligations = uncertain - → requirement = uncertain - -No failures, but one or more required obligations = unproven - → requirement = unproven - -All required obligations = pass - → requirement = pass -``` - -Optional obligations do not block a requirement unless configured. - -## 10.3 Run verdicts - -The entire run receives one of: - -* `pass`; -* `fail`; -* `unproven`; -* `uncertain`; -* `review_required`; -* `error`. - -By default, a CI run succeeds only when the run verdict is `pass`. - -Projects may configure `review_required` as non-blocking, but this must be visible in the report. - -## 10.4 Evidence strength - -Each verifier declares an evidence class: - -| Class | Description | -| --------------- | -------------------------------------------------------------------- | -| `deterministic` | Same inputs should always produce the same result. | -| `probabilistic` | Result may vary because it uses an LLM or stochastic model. | -| `human` | Result depends on explicit human judgment. | -| `informational` | Evidence is recorded but cannot independently satisfy an obligation. | - -A requirement cannot receive `pass` solely from informational evidence. - -Probabilistic evidence may produce `pass` only when the obligation explicitly permits it and the configured confidence threshold is met. - ---- - -# 11. Repository Layout - -A repository using IntentCI should contain: - -```text -.intentci/ -├── config.yaml -├── requirements/ -│ ├── REQ-AUTH-001.md -│ └── REQ-AUTH-002.md -├── providers/ -│ └── optional-local-config.yaml -├── policies/ -│ └── optional-policy-files -├── schemas/ -│ └── optional-project-schemas -└── runs/ - └── ignored-local-run-data -``` - -Recommended `.gitignore` entries: - -```text -.intentci/runs/ -.intentci/cache/ -.intentci/tmp/ -``` - -Committed files: - -* `.intentci/config.yaml`; -* requirement files; -* project-specific policies; -* custom provider definitions. - -Uncommitted files: - -* evidence bundles; -* command logs; -* caches; -* temporary worktrees; -* local agent output. - ---- - -# 12. Requirement Format - -IntentCI v1 uses Markdown with YAML front matter. - -This format preserves human readability while providing deterministic structure. - -## 12.1 Example - -````markdown ---- -id: REQ-AUTH-001 -title: Existing customers can authenticate -status: active -priority: required -owners: - - platform-auth -depends_on: [] -applies_to: - paths: - - src/auth/** - - tests/auth/** - symbols: [] -tags: - - authentication - - customer ---- - -# Intent - -Existing customers must be able to authenticate using their current -credentials without changing the registration flow. - -# Rationale - -Authentication is required for access to customer accounts. - -# Constraints - -## Must - -- id: CON-001 - statement: Reuse the existing session store. - -- id: CON-002 - statement: Preserve the current registration API. - -## Must Not - -- id: CON-003 - statement: Do not add a new authentication dependency. - -- id: CON-004 - statement: Do not modify database migrations. - -# Boundaries - -```yaml -allowed: - - src/auth/** - - tests/auth/** - -forbidden: - - migrations/** - - src/registration/** -``` - -# Obligations - -```yaml -- id: OBL-001 - statement: Valid credentials create a session. - required: true - verify: - all: - - provider: command - id: auth-valid-test - run: pytest tests/auth/test_login.py::test_valid_login - result: - type: exit_code - equals: 0 - -- id: OBL-002 - statement: Invalid credentials do not create a session. - required: true - verify: - all: - - provider: command - id: auth-invalid-test - run: pytest tests/auth/test_login.py::test_invalid_login - result: - type: exit_code - equals: 0 - -- id: OBL-003 - statement: Registration behavior remains unchanged. - required: true - verify: - all: - - provider: junit - id: registration-regression - run: pytest tests/registration --junitxml=.intentci/tmp/registration.xml - report: .intentci/tmp/registration.xml - -- id: OBL-004 - statement: No database migration is added or modified. - required: true - verify: - all: - - provider: boundary - id: no-migration-change - forbidden: - - migrations/** -``` - -```` - ---- - -# 13. Requirement Schema - -## 13.1 Required fields - -Every requirement must contain: - -- `id`; -- `title`; -- `status`; -- `priority`; -- `Intent` section; -- at least one obligation. - -## 13.2 Requirement IDs - -Requirement IDs must match: - -```regex -^[A-Z][A-Z0-9_-]{1,31}-[0-9]{1,8}$ -```` - -Examples: - -```text -REQ-101 -AUTH-001 -PLATFORM_DATA-42 -``` - -IDs must be unique across the repository. - -## 13.3 Requirement statuses - -Supported statuses: - -* `draft`; -* `active`; -* `deprecated`; -* `superseded`; -* `disabled`. - -Only active requirements are verified by default. - -## 13.4 Requirement priority - -Supported priorities: - -* `required`; -* `recommended`; -* `informational`. - -A failed `required` requirement blocks the run. - -A failed `recommended` requirement is reported but does not block by default. - -## 13.5 Obligation fields - -Each obligation must define: - -* `id`; -* `statement`; -* `required`; -* `verify`. - -Optional fields: - -* `description`; -* `rationale`; -* `evidence_class`; -* `timeout`; -* `retry`; -* `platforms`; -* `tags`; -* `depends_on`; -* `manual_review`; -* `severity`. - ---- - -# 14. Verification Expressions - -IntentCI v1 supports these logical operators: - -## 14.1 `all` - -Every verifier must pass. - -```yaml -verify: - all: - - provider: command - ... - - provider: boundary - ... -``` - -## 14.2 `any` - -At least one verifier must pass. - -```yaml -verify: - any: - - provider: junit - ... - - provider: command - ... -``` - -## 14.3 `not` - -The nested verifier must not produce a matching result. - -```yaml -verify: - not: - provider: sarif - report: reports/security.sarif - match: - rule_id: password-in-log -``` - -## 14.4 Nested expressions - -```yaml -verify: - all: - - provider: command - id: compile - run: go test ./... - - any: - - provider: sarif - id: semgrep - report: reports/semgrep.sarif - - provider: command - id: fallback-scan - run: ./scripts/security-scan.sh -``` - -The compiler must reject ambiguous or unsupported expressions. - ---- - -# 15. Configuration - -## 15.1 `.intentci/config.yaml` - -```yaml -version: 1 - -project: - name: example-project - -requirements: - paths: - - .intentci/requirements/**/*.md - -verification: - default_timeout: 10m - max_parallel: 4 - fail_fast: false - working_directory: . - require_clean_worktree: false - -change_impact: - base_ref: origin/main - include_untracked: true - run_unmapped_requirements: false - fail_on_unmapped: false - -evidence: - directory: .intentci/runs - retain_stdout: true - retain_stderr: true - hash_algorithm: sha256 - redact: - environment: - - "*TOKEN*" - - "*SECRET*" - - "*PASSWORD*" - - "*KEY*" - -repair: - max_attempts: 3 - stop_on_repeated_diff: true - stop_on_repeated_failure: true - allow_requirement_changes: false - allow_test_changes: true - protected_paths: [] - -ci: - fail_on: - - fail - - error - - unproven - - uncertain - - review_required - -telemetry: - enabled: false -``` - -## 15.2 Configuration precedence - -Highest to lowest: - -1. command-line flags; -2. environment variables; -3. `.intentci/config.local.yaml`; -4. `.intentci/config.yaml`; -5. built-in defaults. - -Local configuration must not be committed by default. - -Environment overrides use one explicit `INTENTCI_*` name per configuration -leaf. Booleans and integers use Go syntax, durations use Go duration syntax, -and lists are JSON arrays. Implementations must document the complete mapping -and reject malformed values rather than silently falling back. - ---- - -# 16. CLI - -The executable is: - -```bash -intentci -``` - -## 16.1 `intentci init` - -Initializes IntentCI in an existing repository. - -```bash -intentci init -``` - -Creates: - -```text -.intentci/config.yaml -.intentci/requirements/REQ-001.md -``` - -Options: - -```text ---force ---language ---ci github ---no-example -``` - -## 16.2 `intentci compile` - -Parses requirements and creates the canonical Intent IR. - -```bash -intentci compile -``` - -Outputs: - -```text -.intentci/runs//compiled-intent.json -``` - -Checks: - -* schema validity; -* duplicate IDs; -* missing obligations; -* broken dependencies; -* invalid provider configuration; -* contradictory boundaries; -* unsupported logical expressions; -* unreachable requirements; -* missing referenced files; -* cyclic requirement dependencies. - -Options: - -```text ---requirement REQ-AUTH-001 ---format text|json ---output PATH ---strict -``` - -## 16.3 `intentci verify` - -Compiles requirements, selects affected obligations, executes verifiers, and generates verdicts. - -```bash -intentci verify -``` - -Common forms: - -```bash -intentci verify --all - -intentci verify --changed - -intentci verify --requirement REQ-AUTH-001 - -intentci verify --base origin/main - -intentci verify --format json -``` - -Options: - -```text ---all ---changed ---requirement ID ---obligation ID ---base REF ---head REF ---provider ID ---max-parallel N ---fail-fast ---no-cache ---no-git ---format text|json|junit ---output PATH -``` - -## 16.4 `intentci explain` - -Explains the status of a requirement, obligation, verifier, or run. - -```bash -intentci explain REQ-AUTH-001 -``` - -Example output: - -```text -REQ-AUTH-001: Existing customers can authenticate -Verdict: FAIL - -OBL-001 PASS - Valid credentials create a session. - Evidence: pytest exited 0. - -OBL-002 FAIL - Invalid credentials do not create a session. - Evidence: test_invalid_login failed. - -OBL-003 PASS - Registration behavior remains unchanged. - -OBL-004 PASS - No database migration was changed. - -Failure summary: - tests/auth/test_login.py::test_invalid_login - Expected HTTP 401, observed HTTP 500. -``` - -Options: - -```text ---run RUN_ID ---format text|json ---show-evidence ---show-logs -``` - -## 16.5 `intentci repair` - -Runs a bounded implementation-and-verification loop. - -```bash -intentci repair --agent codex -``` - -Generic command adapter: - -```bash -intentci repair \ - --agent-command './scripts/run-agent.sh {packet}' \ - --max-attempts 3 -``` - -Options: - -```text ---agent NAME ---agent-command COMMAND ---requirement ID ---changed ---max-attempts N ---allow-test-changes ---dry-run -``` - -`--agent NAME` resolves `intentci-agent-NAME` on `PATH` and is mutually -exclusive with `--agent-command`. Version 1 executes repair directly in the -current worktree and must warn that it is not sandboxed. Isolated worktrees are -deferred beyond v1. - -## 16.6 `intentci status` - -Shows repository-level IntentCI status. - -```bash -intentci status -``` - -Example: - -```text -Requirements: 24 active -Verified: 18 -Failed: 2 -Unproven: 3 -Uncertain: 1 -Last run: 2026-07-26T09:42:11Z -Commit: a3d8c91 -``` - -## 16.7 `intentci doctor` - -Checks local dependencies and configuration. - -```bash -intentci doctor -``` - -Checks: - -* Git repository availability; -* configuration validity; -* command-provider executables; -* writable evidence directory; -* supported platform; -* required report files; -* agent adapter configuration; -* optional CI environment. - -## 16.8 `intentci schema` - -Prints or exports the current JSON schemas. - -```bash -intentci schema requirement -intentci schema evidence -intentci schema verdict -``` - ---- - -# 17. Exit Codes - -| Code | Meaning | -| ---: | --------------------------------------- | -| `0` | Verification passed. | -| `1` | One or more requirements failed. | -| `2` | One or more requirements are unproven. | -| `3` | One or more requirements are uncertain. | -| `4` | Human review is required. | -| `5` | Requirement compilation failed. | -| `6` | Verifier execution error. | -| `7` | IntentCI internal error. | -| `8` | Invalid command-line usage. | -| `9` | Repair attempts exhausted. | -| `10` | Security or boundary violation. | - -Projects may map non-pass verdicts differently in CI, but IntentCI must always preserve the original verdict in its output. - ---- - -# 18. Provider System - -Providers convert existing tools into normalized IntentCI evidence. - -## 18.1 Provider contract - -Every provider receives: - -```json -{ - "run_id": "run-01J3...", - "attempt_id": "attempt-1", - "requirement_id": "REQ-AUTH-001", - "obligation_id": "OBL-002", - "repository": { - "root": "/repo", - "commit": "a3d8c91", - "base_commit": "bb20e44" - }, - "configuration": {}, - "timeout_ms": 600000 -} -``` - -Every provider returns: - -```json -{ - "provider": "command", - "provider_version": "1.0.0", - "status": "completed", - "evidence": [], - "diagnostics": [] -} -``` - -Providers must not directly assign the final requirement verdict. - -They produce evidence. The IntentCI verdict engine interprets it. - -## 18.2 Required v1 providers - -### Command provider - -Runs an arbitrary command. - -```yaml -provider: command -run: pytest tests/auth -result: - type: exit_code - equals: 0 -``` - -Supports: - -* working directory; -* environment variables; -* timeout; -* exit-code expectation; -* stdout matching; -* stderr matching; -* generated artifact collection. - -### JUnit provider - -Runs a command or reads an existing JUnit XML report. - -```yaml -provider: junit -run: pytest tests/auth --junitxml=.intentci/tmp/auth.xml -report: .intentci/tmp/auth.xml -``` - -Extracts: - -* suites; -* tests; -* failures; -* errors; -* skipped tests; -* durations; -* failure messages. - -When `run` is present, the report must be created by that invocation. A stale -pre-existing report is never reused. If the generator exits nonzero and the -fresh report passes or is missing, the provider returns `error`; a fresh report -containing violations returns `fail`. - -### SARIF provider - -Reads a SARIF report and evaluates matching findings. - -```yaml -provider: sarif -run: semgrep scan --sarif --output reports/semgrep.sarif -report: reports/semgrep.sarif -allow: - max_findings: 0 - levels: - - error -``` - -Supports filtering by: - -* rule ID; -* severity; -* path; -* result level; -* baseline state. - -Generated SARIF reports follow the same freshness and generator-exit rules as -JUnit reports. - -### Boundary provider - -Evaluates changed files against allowed and forbidden patterns. - -```yaml -provider: boundary -allowed: - - src/auth/** - - tests/auth/** -forbidden: - - migrations/** -``` - -### Git-diff provider - -Evaluates: - -* changed files; -* line counts; -* renamed files; -* deleted files; -* binary files; -* dependency-file changes. - -### JSON provider - -Reads a JSON file and evaluates a JSONPath-compatible expression. - -```yaml -provider: json -report: reports/coverage.json -assert: - path: $.totals.percent_covered - gte: 90 -``` - -The v1 expression subset supports `$`, member access, and array indexes, with -`exists`, `equals`, `not_equals`, `gt`, `gte`, `lt`, and `lte`. Unsupported -expressions are compile errors. - -### Manual-review provider - -Produces `review_required`. - -```yaml -provider: manual -prompt: Confirm that the user-facing flow matches the approved design. -``` - -### Agent-command adapter - -Executes an external coding agent as a subprocess. - -The adapter is not a verifier. It is used only by the repair loop. - -## 18.3 Deferred providers - -Not required for v1: - -* OPA-native provider; -* Phoenix provider; -* Langfuse provider; -* OpenTelemetry collector provider; -* Pact provider; -* Schemathesis provider; -* coverage-specific providers; -* mutation-testing providers; -* browser screenshot provider; -* GitHub Checks provider; -* remote-execution provider. - -These tools can initially be invoked through the command, JSON, JUnit, or SARIF providers. - ---- - -# 19. Evidence Model - -## 19.1 Evidence record - -```json -{ - "schema_version": "1.0", - "evidence_id": "EVD-01J3...", - "run_id": "RUN-01J3...", - "attempt_id": "ATT-001", - "requirement_id": "REQ-AUTH-001", - "obligation_id": "OBL-002", - "verifier_id": "auth-invalid-test", - "provider": { - "name": "command", - "version": "1.0.0" - }, - "classification": "deterministic", - "observation": { - "type": "command_result", - "status": "failed", - "exit_code": 1, - "duration_ms": 2834 - }, - "command": { - "executable": "pytest", - "arguments": [ - "tests/auth/test_login.py::test_invalid_login" - ], - "working_directory": "." - }, - "repository": { - "commit": "a3d8c91", - "base_commit": "bb20e44", - "diff_hash": "sha256:..." - }, - "contract": { - "requirement_hash": "sha256:...", - "obligation_hash": "sha256:...", - "verification_plan_hash": "sha256:..." - }, - "artifacts": [ - { - "path": "logs/auth-invalid-test.stdout", - "sha256": "sha256:...", - "media_type": "text/plain" - } - ], - "timestamps": { - "started_at": "2026-07-26T09:42:11Z", - "completed_at": "2026-07-26T09:42:14Z" - } -} -``` - -## 19.2 Evidence requirements - -Each evidence record must include: - -* requirement ID; -* obligation ID; -* verifier ID; -* provider identity and version; -* repository commit; -* diff hash; -* requirement hash; -* verification-plan hash; -* start and completion timestamps; -* status; -* artifact hashes. - -## 19.3 Evidence bundle - -Each run produces: - -```text -.intentci/runs// -├── manifest.json -├── compiled-intent.json -├── verification-plan.json -├── repository-state.json -├── diff.patch -├── attempts/ -│ ├── attempt-001/ -│ │ ├── evidence.json -│ │ ├── verdict.json -│ │ ├── repair-packet.json -│ │ ├── logs/ -│ │ └── artifacts/ -│ └── attempt-002/ -├── final-verdict.json -├── report.txt -├── report.json -└── report.junit.xml -``` - -## 19.4 Manifest - -The manifest hashes every immutable run input, attempt artifact, log, and report -except `manifest.json` itself and `final-verdict.json`. The final verdict records -the SHA-256 hash of `manifest.json`, avoiding a hash cycle. - -```json -{ - "schema_version": "1.0", - "run_id": "RUN-01J3...", - "hash_algorithm": "sha256", - "artifacts": [ - { - "path": "compiled-intent.json", - "sha256": "..." - }, - { - "path": "verification-plan.json", - "sha256": "..." - } - ] -} -``` - -IntentCI v1 does not need digital signing, but its schemas must allow signatures to be added later. - ---- - -# 20. Requirement Compilation - -The compiler transforms repository requirement files into canonical JSON IR. - -## 20.1 Compilation stages - -```text -Discover -→ Parse -→ Normalize -→ Validate -→ Resolve dependencies -→ Expand verification expressions -→ Build requirement graph -→ Build verification plan -→ Hash contracts -→ Emit IR -``` - -## 20.2 Compiler validation - -The compiler must reject: - -* malformed YAML; -* duplicate requirement IDs; -* duplicate obligation IDs within a requirement; -* missing required fields; -* empty intent statements; -* requirements without obligations; -* circular dependencies; -* unknown providers; -* invalid path patterns; -* conflicting allowed and forbidden boundaries; -* invalid logical expressions; -* invalid references; -* unsupported schema versions; -* verifier IDs reused incompatibly; -* required obligations that have no verification mechanism. - -## 20.3 Compiler warnings - -The compiler should warn about: - -* requirements with no `applies_to` mapping; -* requirements with only probabilistic evidence; -* broad file boundaries such as `**/*`; -* obligations that rely only on exit codes; -* commands without timeouts; -* requirements with no owner; -* disabled requirements referenced by active requirements; -* tests that are inside the allowed implementation boundary; -* verification commands that modify tracked files. - -Warnings become errors under: - -```bash -intentci compile --strict -``` - ---- - -# 21. Change-Impact Analysis - -IntentCI v1 uses explicit mappings rather than attempting full semantic code analysis. - -## 21.1 Inputs - -Change-impact analysis uses: - -* Git base commit; -* Git head commit; -* changed paths; -* renamed paths; -* deleted paths; -* requirement `applies_to.paths`; -* verifier input paths; -* dependency files; -* requirement dependencies. - -## 21.2 Selection rules - -A requirement is affected when: - -1. a changed path matches its `applies_to.paths`; -2. a changed path matches one of its verifier input paths; -3. one of its dependent requirements is affected; -4. its requirement file changed; -5. global IntentCI configuration changed; -6. a shared provider configuration changed. - -## 21.3 Global invalidation - -Changes to these files invalidate all requirements by default: - -```text -.intentci/config.yaml -.intentci/providers/** -.intentci/schemas/** -go.mod -package-lock.json -pyproject.toml -Cargo.lock -``` - -Projects may customize this list. - -## 21.4 Unmapped changes - -If a changed file maps to no requirement, IntentCI reports it as an unmapped change. - -Default v1 behavior: - -* report a warning; -* run global obligations; -* do not fail unless `change_impact.fail_on_unmapped` is enabled. - ---- - -# 22. Verification Execution - -## 22.1 Execution model - -IntentCI builds a directed acyclic graph of verifier executions. - -Verifiers may run concurrently when they: - -* have no dependency relationship; -* do not write to the same declared output; -* do not require exclusive execution; -* fit within the configured concurrency limit. - -## 22.2 Working directory - -By default, providers run in the repository root. - -Each provider may specify: - -```yaml -working_directory: services/auth -``` - -## 22.3 Environment - -IntentCI passes a minimal environment plus explicitly allowed variables. - -Injected variables: - -```text -INTENTCI_RUN_ID -INTENTCI_ATTEMPT_ID -INTENTCI_REQUIREMENT_ID -INTENTCI_OBLIGATION_ID -INTENTCI_BASE_COMMIT -INTENTCI_HEAD_COMMIT -INTENTCI_EVIDENCE_DIR -``` - -## 22.4 Timeouts - -Every verifier must have a timeout. - -Resolution order: - -1. verifier timeout; -2. requirement timeout; -3. project default; -4. built-in default of 10 minutes. - -A timeout produces `error`, not `fail`, unless explicitly configured otherwise. - -## 22.5 Retries - -Deterministic verifiers do not retry by default. - -Probabilistic or infrastructure-sensitive verifiers may define: - -```yaml -retry: - attempts: 2 - backoff: 5s -``` - -Retries are recorded as separate evidence observations. - -## 22.6 Caching - -A verifier result may be reused only when these hashes match: - -* repository commit and dirty diff hash, or equivalent relevant input hashes; -* requirement hash; -* obligation hash; -* verification-plan and verifier configuration hashes; -* provider version; -* relevant environment fingerprint; -* declared provider input hashes. - -Caching is enabled by default for successful deterministic verifiers. - -Failed, uncertain, manual, and probabilistic results are not cached by default. - ---- - -# 23. Independent Verification - -IntentCI must separate implementation from verification. - -## 23.1 Contract immutability - -At the beginning of an attempt, IntentCI hashes: - -* requirement files; -* verification configuration; -* required test selectors; -* allowed and forbidden boundaries; -* repository base commit. - -The implementation agent may not modify these without invalidating the attempt. - -## 23.2 Protected paths - -Default protected paths during repair: - -```text -.intentci/** -.github/workflows/** -``` - -Projects may add: - -```text -tests/acceptance/** -security-rules/** -contracts/** -``` - -## 23.3 Test changes - -Test changes are allowed only when explicitly enabled. - -When test changes are allowed: - -* changed tests are reported separately; -* test-file hashes become part of the attempt; -* newly changed tests cannot erase previously required selectors; -* removal or weakening of required checks causes a boundary failure. - -## 23.4 Verification process - -The coding agent never directly reports a passing verdict. - -IntentCI reruns verifiers in a separate subprocess after the agent exits. - ---- - -# 24. Repair Loop - -`repair.max_attempts` is the total number of immutable verification attempts, -including the initial failed state. IntentCI must not invoke an agent after the -final permitted verification attempt. - -## 24.1 Workflow - -```text -Compile immutable contract - ↓ -Run verification - ↓ -Pass? ─────────────── yes → finalize - │ - no - ↓ -Create repair packet - ↓ -Run coding agent - ↓ -Validate changed boundaries - ↓ -Recalculate affected obligations - ↓ -Run verification - ↓ -Repeat until pass or stop condition -``` - -## 24.2 Repair packet - -```json -{ - "schema_version": "1.0", - "run_id": "RUN-01J3...", - "attempt": 2, - "max_attempts": 3, - "repository": { - "base_commit": "bb20e44", - "current_commit": "a3d8c91" - }, - "requirements": [ - { - "id": "REQ-AUTH-001", - "title": "Existing customers can authenticate", - "intent": "Existing customers must be able to authenticate.", - "boundaries": { - "allowed": [ - "src/auth/**", - "tests/auth/**" - ], - "forbidden": [ - "migrations/**", - "src/registration/**" - ] - }, - "failed_obligations": [ - { - "id": "OBL-002", - "statement": "Invalid credentials do not create a session.", - "verdict": "fail", - "evidence": { - "summary": "Expected HTTP 401, observed HTTP 500.", - "artifact": "attempts/attempt-001/logs/auth-invalid-test.stdout" - } - } - ] - } - ], - "instructions": [ - "Modify only files allowed by the requirement boundaries.", - "Do not modify requirement files.", - "Do not remove or weaken required verification.", - "Resolve the failed obligations.", - "Stop after making the implementation changes." - ] -} -``` - -## 24.3 Agent adapter protocol - -The generic agent command receives: - -```text -{packet} -{repository} -{attempt} -``` - -Example: - -```bash -intentci repair \ - --agent-command 'codex exec --input {packet}' -``` - -The adapter must return: - -* exit code; -* stdout; -* stderr; -* optional structured metadata. - -IntentCI does not trust the agent’s claimed status. - -Named agents use the same versioned JSON packet contract and resolve -`intentci-agent-NAME` on `PATH`. - -## 24.4 Stop conditions - -The repair loop stops when: - -* all required requirements pass; -* maximum attempts are reached; -* the same diff hash appears twice; -* the same failure fingerprint appears twice without changed relevant files; -* a protected file is modified; -* a forbidden boundary is crossed; -* the agent command errors repeatedly; -* the contract changes; -* a human-review obligation is reached; -* the user interrupts execution. - -## 24.5 Failure fingerprint - -A failure fingerprint includes: - -* requirement ID; -* obligation ID; -* verifier ID; -* normalized failure type; -* relevant message hash; -* affected file set. - -This detects unproductive loops while ignoring volatile timestamps and stack-frame line numbers where possible. - ---- - -# 25. Reports - -IntentCI v1 produces four output formats. - -## 25.1 Terminal report - -Optimized for local development. - -```text -IntentCI verification - -Run: RUN-01J3W9Y -Commit: a3d8c91 -Base: bb20e44 -Duration: 14.2s - -Requirements - PASS REQ-AUTH-001 Existing customers can authenticate - FAIL REQ-AUTH-002 Sessions expire after inactivity - -Summary - Requirements: 2 - Passed: 1 - Failed: 1 - Obligations: 7 - Evidence: 11 - -Final verdict: FAIL -``` - -## 25.2 JSON report - -Stable machine-readable schema containing: - -* run metadata; -* requirement verdicts; -* obligation verdicts; -* evidence references; -* artifacts; -* diagnostics; -* final verdict. - -## 25.3 JUnit report - -Each requirement is represented as a test suite. - -Each obligation is represented as a test case. - -This allows existing CI platforms to display IntentCI results without custom integration. - -## 25.4 GitHub step summary - -When running in GitHub Actions, IntentCI writes a Markdown summary to: - -```text -$GITHUB_STEP_SUMMARY -``` - -The summary includes: - -* final verdict; -* affected requirements; -* failed obligations; -* unmapped changed files; -* evidence artifact location; -* repair-attempt count. - ---- - -# 26. GitHub Actions Integration - -Example workflow: - -```yaml -name: IntentCI - -on: - pull_request: - push: - branches: - - main - -jobs: - verify-intent: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install IntentCI - run: | - curl -fsSL https://example.invalid/install-intentci.sh | sh - - - name: Verify changed requirements - run: | - intentci verify \ - --changed \ - --base origin/${{ github.base_ref }} \ - --format json \ - --output .intentci/report.json - - - name: Upload evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: intentci-evidence - path: .intentci/runs/ -``` - -The actual release must use a version-pinned installation method and published checksums. - ---- - -# 27. Security Model - -## 27.1 Trust boundaries - -IntentCI executes repository-defined commands. Therefore, running IntentCI on an untrusted repository is equivalent to running the repository’s build or test scripts. - -IntentCI must state this clearly. - -## 27.2 Secret handling - -IntentCI must: - -* avoid writing complete environment snapshots; -* redact configured secret-name patterns; -* support stdout and stderr redaction; -* avoid embedding secrets in repair packets; -* warn when command definitions contain literal credentials; -* prevent evidence directories from being committed accidentally. - -## 27.3 Command execution - -Version 1 runs commands directly on the host. - -It must not claim sandboxing. - -Future versions may support: - -* Docker; -* dev containers; -* isolated worktrees; -* remote runners; -* operating-system sandboxing. - -## 27.4 Path safety - -Artifact paths must remain under the configured evidence directory. - -Providers must reject path traversal such as: - -```text -../../outside-repository -``` - -## 27.5 Symlinks - -IntentCI must not follow symlinks outside the repository when: - -* collecting evidence; -* evaluating boundaries; -* hashing protected paths. - -## 27.6 Evidence integrity - -Every evidence artifact must be hashed after collection. - -The final verdict must reference the hash of the evidence bundle manifest. - -## 27.7 Agent permissions - -The repair command runs with the current user’s permissions in v1. - -IntentCI must warn when repair mode is used outside an isolated environment. - ---- - -# 28. Performance Requirements - -IntentCI v1 should meet these targets on a normal developer laptop: - -| Operation | Target | -| ------------------------------------------ | --------------------------: | -| Start CLI | Under 100 ms | -| Compile 100 requirements | Under 500 ms | -| Compile 1,000 requirements | Under 3 seconds | -| Change-impact analysis on 10,000 files | Under 2 seconds | -| Evidence aggregation for 10,000 test cases | Under 2 seconds | -| Incremental verifier scheduling overhead | Under 100 ms | -| Idle memory usage | Under 100 MB | -| Packaged binary size | Under 50 MB where practical | - -Verifier runtime is excluded because it depends on external tools. - ---- - -# 29. Reliability Requirements - -IntentCI must: - -* write evidence atomically; -* preserve completed evidence after interruption; -* avoid corrupting previous runs; -* use unique sortable run IDs; -* handle `SIGINT` and `SIGTERM`; -* mark interrupted verifiers as errors; -* retain partial logs; -* never report `pass` after an incomplete run; -* validate evidence before verdict aggregation; -* produce a useful internal-error diagnostic without exposing secrets. - ---- - -# 30. Compatibility - -## 30.1 Operating systems - -Required: - -* Linux; -* macOS. - -Best-effort for v1: - -* Windows. - -## 30.2 Git - -Git is required for: - -* changed-mode verification; -* boundary checks; -* repair mode; -* evidence provenance. - -Full verification may run without Git when: - -```bash -intentci verify --all --no-git -``` - -Provenance will be marked incomplete. - -## 30.3 Languages - -IntentCI is language-agnostic through command providers. - -Initial examples and documentation should cover: - -* Python; -* TypeScript; -* Go; -* Rust; -* Java. - ---- - -# 31. Internal Architecture - -## 31.1 Components - -```text -cmd/ - CLI commands - -internal/config/ - configuration loading and validation - -internal/parser/ - Markdown and YAML parsing - -internal/ir/ - canonical requirement representation - -internal/compiler/ - dependency resolution and verification planning - -internal/impact/ - Git diff and affected-requirement selection - -internal/provider/ - provider interfaces and built-in providers - -internal/executor/ - process execution, concurrency, and timeouts - -internal/evidence/ - evidence records, hashing, and artifact storage - -internal/verdict/ - evidence interpretation and aggregation - -internal/repair/ - repair packets and bounded agent loops - -internal/report/ - terminal, JSON, JUnit, and GitHub output - -internal/security/ - redaction, path safety, and protected-file checks -``` - -## 31.2 Suggested repository layout - -```text -intentci/ -├── cmd/ -│ └── intentci/ -├── internal/ -├── pkg/ -│ └── schema/ -├── schemas/ -├── examples/ -│ ├── python/ -│ ├── typescript/ -│ ├── go/ -│ └── rust/ -├── docs/ -├── tests/ -│ ├── fixtures/ -│ ├── integration/ -│ └── e2e/ -├── .github/ -├── go.mod -├── LICENSE -└── README.md -``` - -## 31.3 Persistence - -Version 1 uses filesystem-based persistence. - -SQLite is optional but unnecessary for the first release. - -Evidence bundles are the source of truth. - ---- - -# 32. Public Go Interfaces - -## 32.1 Provider - -```go -type Provider interface { - Name() string - Version() string - Validate(config ProviderConfig) []Diagnostic - Execute( - ctx context.Context, - request VerificationRequest, - ) ProviderResult -} -``` - -## 32.2 Evidence evaluator - -```go -type EvidenceEvaluator interface { - Evaluate( - obligation Obligation, - evidence []Evidence, - ) ObligationVerdict -} -``` - -## 32.3 Reporter - -```go -type Reporter interface { - Render( - ctx context.Context, - run VerificationRun, - output io.Writer, - ) error -} -``` - -These interfaces should remain internal until extension stability is demonstrated. - -External providers in v1 should use a subprocess protocol rather than compiled Go plugins. - ---- - -# 33. External Provider Protocol - -Custom providers may be executable programs. - -An unknown provider name `NAME` resolves to `intentci-provider-NAME` on `PATH`. - -IntentCI sends a JSON request over stdin: - -```bash -intentci-provider-custom -``` - -The provider returns JSON over stdout. - -Rules: - -* stdout must contain only the protocol response; -* diagnostics go to stderr; -* the provider must declare its protocol version; -* unknown fields are ignored where safe; -* incompatible major versions are rejected; -* execution is subject to IntentCI timeout and environment rules. - -This provides extensibility without Go plugin compatibility problems. - ---- - -# 34. Testing Strategy - -## 34.1 Unit tests - -Required for: - -* parser; -* schema validation; -* dependency graph; -* boundary matching; -* change-impact selection; -* verdict aggregation; -* evidence hashing; -* redaction; -* repair stop conditions; -* CLI exit-code mapping. - -## 34.2 Golden tests - -Golden fixtures should validate: - -* compiled IR; -* verification plans; -* JSON reports; -* terminal reports; -* JUnit reports; -* repair packets. - -## 34.3 Integration tests - -Run real commands against fixture repositories. - -Required scenarios: - -* passing Python project; -* failing TypeScript project; -* SARIF findings; -* JUnit failures; -* forbidden-file changes; -* timed-out verifier; -* malformed report; -* interrupted command; -* unmapped changed file; -* cached verifier result; -* modified requirement during repair; -* repeated repair failure. - -## 34.4 End-to-end tests - -Required workflows: - -```text -init → compile → verify → explain -``` - -and: - -```text -verify fail → repair agent → verify pass -``` - -A deterministic fake coding agent should be used for automated repair-loop tests. - -## 34.5 Property-based tests - -Use property-based testing for: - -* path matching; -* dependency graphs; -* verdict aggregation; -* manifest hashing; -* redaction; -* run-ID ordering; -* logical expression normalization. - -## 34.6 Mutation testing - -Mutation testing is recommended for: - -* verdict aggregation; -* compiler validation; -* boundary enforcement; -* contract immutability. - ---- - -# 35. Functional Requirements - -## 35.1 Requirement management - -| ID | Requirement | -| ------ | ------------------------------------------------------------------------ | -| FR-001 | IntentCI shall discover requirement files from configured glob patterns. | -| FR-002 | IntentCI shall parse Markdown files with YAML front matter. | -| FR-003 | IntentCI shall reject duplicate requirement IDs. | -| FR-004 | IntentCI shall resolve requirement dependencies. | -| FR-005 | IntentCI shall reject cyclic dependencies. | -| FR-006 | IntentCI shall emit canonical JSON IR. | -| FR-007 | IntentCI shall hash compiled requirements and obligations. | - -## 35.2 Verification - -| ID | Requirement | -| ------ | ---------------------------------------------------------------- | -| FR-101 | IntentCI shall execute arbitrary commands as verifiers. | -| FR-102 | IntentCI shall parse JUnit XML. | -| FR-103 | IntentCI shall parse SARIF JSON. | -| FR-104 | IntentCI shall evaluate file boundaries. | -| FR-105 | IntentCI shall evaluate JSON reports. | -| FR-106 | IntentCI shall enforce verifier timeouts. | -| FR-107 | IntentCI shall execute independent verifiers concurrently. | -| FR-108 | IntentCI shall preserve verifier stdout and stderr. | -| FR-109 | IntentCI shall normalize verifier outputs into evidence records. | - -## 35.3 Verdicts - -| ID | Requirement | -| ------ | ----------------------------------------------------------------------- | -| FR-201 | IntentCI shall assign one verdict to every obligation. | -| FR-202 | IntentCI shall aggregate obligation verdicts into requirement verdicts. | -| FR-203 | IntentCI shall aggregate requirement verdicts into a run verdict. | -| FR-204 | IntentCI shall distinguish failure from missing evidence. | -| FR-205 | IntentCI shall distinguish deterministic from probabilistic evidence. | -| FR-206 | IntentCI shall never produce pass from incomplete required evidence. | - -## 35.4 Change impact - -| ID | Requirement | -| ------ | ----------------------------------------------------------------- | -| FR-301 | IntentCI shall calculate changed files from Git. | -| FR-302 | IntentCI shall map changed files to requirements. | -| FR-303 | IntentCI shall propagate impact through requirement dependencies. | -| FR-304 | IntentCI shall report unmapped changes. | -| FR-305 | IntentCI shall support full-verification mode. | - -## 35.5 Evidence - -| ID | Requirement | -| ------ | ------------------------------------------------------------------------- | -| FR-401 | IntentCI shall create one evidence bundle per run. | -| FR-402 | IntentCI shall hash all collected artifacts. | -| FR-403 | IntentCI shall associate evidence with a commit and diff. | -| FR-404 | IntentCI shall associate evidence with requirement and obligation hashes. | -| FR-405 | IntentCI shall retain partial evidence after interruption. | -| FR-406 | IntentCI shall redact configured secret patterns. | - -## 35.6 Repair - -| ID | Requirement | -| ------ | ---------------------------------------------------------------------- | -| FR-501 | IntentCI shall create structured repair packets. | -| FR-502 | IntentCI shall invoke a coding agent through an external command. | -| FR-503 | IntentCI shall independently rerun verification after agent execution. | -| FR-504 | IntentCI shall enforce a maximum repair-attempt count. | -| FR-505 | IntentCI shall detect repeated diffs. | -| FR-506 | IntentCI shall detect repeated failure fingerprints. | -| FR-507 | IntentCI shall reject protected contract modifications. | -| FR-508 | IntentCI shall stop on forbidden boundary changes. | - -## 35.7 Reporting - -| ID | Requirement | -| ------ | ------------------------------------------------------- | -| FR-601 | IntentCI shall produce terminal reports. | -| FR-602 | IntentCI shall produce JSON reports. | -| FR-603 | IntentCI shall produce JUnit reports. | -| FR-604 | IntentCI shall support GitHub step summaries. | -| FR-605 | IntentCI shall explain individual requirement verdicts. | - ---- - -# 36. User Experience Requirements - -## 36.1 First-run experience - -A new user should be able to run: - -```bash -intentci init -intentci verify -``` - -within five minutes in an existing repository. - -## 36.2 Error messages - -Errors must include: - -* what failed; -* where it failed; -* the relevant requirement or configuration path; -* how to correct it where possible. - -Poor: - -```text -Invalid configuration. -``` - -Required: - -```text -.intentci/requirements/REQ-AUTH-001.md:47 - -OBL-003 references unknown provider "pytests". -Did you mean "command" or "junit"? -``` - -## 36.3 Progressive adoption - -Repositories should be able to begin with: - -* one requirement; -* one shell verifier; -* one CI job. - -They should not need to model the entire system before receiving value. - ---- - -# 37. V1 Milestones - -## Milestone 1: Compiler foundation - -Deliver: - -* CLI skeleton; -* configuration loader; -* Markdown and YAML parser; -* requirement schema; -* obligation schema; -* canonical JSON IR; -* validation diagnostics; -* dependency graph; -* `init`; -* `compile`; -* `schema`. - -Exit criteria: - -* 100 requirements compile deterministically; -* malformed contracts produce precise diagnostics; -* golden IR tests pass. - -## Milestone 2: Verification engine - -Deliver: - -* provider interface; -* command provider; -* boundary provider; -* Git-diff provider; -* executor; -* timeouts; -* logs; -* evidence records; -* verdict engine; -* `verify`. - -Exit criteria: - -* passing and failing commands are classified correctly; -* forbidden changes fail reliably; -* incomplete execution never produces pass. - -## Milestone 3: Report adapters - -Deliver: - -* JUnit provider; -* SARIF provider; -* JSON provider; -* terminal report; -* JSON report; -* JUnit report; -* GitHub step summary; -* `explain`; -* `status`. - -Exit criteria: - -* fixture reports from major test and scanning tools parse correctly; -* reports retain requirement-to-evidence traceability. - -## Milestone 4: Incremental verification - -Deliver: - -* Git base/head selection; -* path-based impact analysis; -* dependency propagation; -* unmapped-change reporting; -* deterministic cache. - -Exit criteria: - -* changed-mode selects the expected requirements across fixture repositories; -* cache invalidates on relevant contract, provider, or input changes. - -## Milestone 5: Repair loop - -Deliver: - -* repair-packet schema; -* generic agent-command adapter; -* contract immutability checks; -* protected paths; -* attempt management; -* repeated-diff detection; -* repeated-failure detection; -* `repair`. - -Exit criteria: - -* deterministic fake agent can repair a fixture repository; -* malicious contract modification is rejected; -* exhausted attempts produce complete evidence. - -## Milestone 6: Release readiness - -Deliver: - -* Linux and macOS binaries; -* checksums; -* installation documentation; -* GitHub Actions example; -* Python, TypeScript, Go, and Rust examples; -* security documentation; -* migration policy for schemas; -* contributor guide; -* release automation. - ---- - -# 38. V1 Release Acceptance Criteria - -IntentCI v1 is complete when all of the following are true: - -1. A user can initialize IntentCI in an existing repository. -2. Requirements can be authored in human-readable Markdown. -3. Requirement files compile into stable canonical JSON. -4. Invalid requirement graphs fail with actionable diagnostics. -5. Existing test commands can verify obligations. -6. JUnit and SARIF reports can be mapped to obligations. -7. File-boundary violations are detected. -8. Changed files can select affected requirements. -9. Evidence is tied to the repository state and contract hashes. -10. Every required obligation receives an explicit verdict. -11. Missing evidence cannot produce a passing requirement. -12. A failed requirement produces a structured repair packet. -13. An external coding agent can be invoked for bounded repair attempts. -14. The agent cannot silently modify protected contracts. -15. Repeated ineffective attempts are stopped. -16. Terminal, JSON, and JUnit reports are generated. -17. GitHub Actions can use the CLI without a custom service. -18. Linux and macOS are supported. -19. No telemetry is sent by default. -20. The complete end-to-end workflow is covered by automated tests. - ---- - -# 39. Success Metrics - -## Adoption - -* time from installation to first verified requirement; -* number of repositories using IntentCI; -* number of active requirements per repository; -* number of external provider integrations. - -## Reliability - -* percentage of runs producing complete evidence bundles; -* internal-error rate; -* cache correctness rate; -* false-pass incidents; -* false-boundary detections. - -## Agent effectiveness - -* percentage of failed attempts repaired within the configured budget; -* median number of repair attempts; -* repeated-failure stop rate; -* percentage of agent changes rejected for boundary violations. - -## Developer value - -* reduction in manual CI-log interpretation; -* percentage of pull requests with requirement traceability; -* percentage of changed files mapped to requirements; -* percentage of obligations supported by deterministic evidence. - -The most important safety metric is: - -> IntentCI must have zero known cases where incomplete or invalid evidence was reported as a passing required obligation. - ---- - -# 40. Risks and Mitigations - -## Risk: Excessive configuration - -**Mitigation:** Support simple shell verifiers, generate examples during initialization, and provide clear defaults. - -## Risk: Tests do not actually prove requirements - -**Mitigation:** Represent this honestly. IntentCI proves only that configured evidence satisfies configured obligations. Add warnings for weak mappings and future mutation-analysis support. - -## Risk: Agents weaken tests - -**Mitigation:** Hash contracts, protect paths, distinguish implementation changes from verification changes, and rerun checks independently. - -## Risk: Tool becomes a generic CI wrapper - -**Mitigation:** Keep requirement semantics, obligation compilation, evidence graphs, and verdict logic central to the product. - -## Risk: Tool tries to replace too much - -**Mitigation:** Avoid custom test runners, policy languages, hosted execution, and agent frameworks. - -## Risk: Path mappings are incomplete - -**Mitigation:** Report unmapped changes and provide strict mode. - -## Risk: Shell commands expose secrets - -**Mitigation:** Minimize environment capture, redact outputs, and document trust boundaries. - -## Risk: Probabilistic evaluators create unstable verdicts - -**Mitigation:** Label evidence classes, require explicit confidence rules, and default probabilistic failures to `uncertain`. - -## Risk: Repair loops waste compute - -**Mitigation:** Bound attempts, detect repeated diffs and failures, and rerun only affected obligations. - -## Risk: Schema changes break repositories - -**Mitigation:** Version every schema and provide explicit migration commands in later versions. - ---- - -# 41. Deferred Post-v1 Features - -Potential v1.1 and v2 capabilities: - -* OPA-native provider; -* OpenTelemetry trace export; -* Phoenix and Langfuse evaluator providers; -* hosted evidence viewer; -* GitHub Checks annotations; -* GitLab integration; -* containerized verifier execution; -* distributed execution through Temporal; -* requirement-to-symbol mappings through SCIP or language servers; -* semantic change-impact analysis; -* automatic test discovery; -* mutation-testing-based evidence strength; -* signed evidence and SLSA-style provenance; -* pull-request comment bot; -* IDE extension; -* requirement graph visualization; -* reusable organization-level policy packs; -* requirement templates; -* automatic draft-requirement generation; -* human approval UI; -* organization-level evidence retention; -* agent-specific adapters; -* cross-repository requirements; -* requirement coverage scoring; -* evaluator consensus and calibration; -* support for design screenshots and browser evidence. - ---- - -# 42. Example End-to-End Workflow - -## Step 1: Create requirement - -```bash -intentci init -``` - -The developer edits: - -```text -.intentci/requirements/REQ-AUTH-001.md -``` - -## Step 2: Compile - -```bash -intentci compile --strict -``` - -Output: - -```text -Compiled 1 requirement and 4 obligations. -Verification plan contains 4 verifier executions. -``` - -## Step 3: Agent implements the change - -The developer uses any coding agent. - -## Step 4: Verify changed requirements - -```bash -intentci verify --changed --base origin/main -``` - -Output: - -```text -FAIL REQ-AUTH-001 - PASS OBL-001 - FAIL OBL-002 - PASS OBL-003 - PASS OBL-004 -``` - -## Step 5: Inspect failure - -```bash -intentci explain REQ-AUTH-001 --show-evidence -``` - -## Step 6: Run bounded repair - -```bash -intentci repair \ - --agent-command './scripts/run-codex.sh {packet}' \ - --max-attempts 3 -``` - -## Step 7: Final result - -```text -Attempt 1: FAIL -Attempt 2: PASS - -REQ-AUTH-001: PASS -Evidence bundle: .intentci/runs/RUN-01J3... -``` - -## Step 8: CI validates independently - -The pull request runs the same verification without trusting local state. - ---- - -# 43. Positioning - -IntentCI is not: - -* another coding agent; -* another test framework; -* another CI platform; -* another agent orchestration graph; -* another prompt-management tool. - -IntentCI is: - -> A language-agnostic intent compiler and evidence-based verification control plane for agent-generated code. - -The closest analogy is: - -```text -Gherkin + Cucumber - plus -policy-as-code - plus -evidence provenance - plus -agent repair loops -``` - -Its differentiation is the semantic chain: - -```text -Requirement -→ obligation -→ verifier -→ evidence -→ verdict -→ repair -``` - ---- - -# 44. Final V1 Product Boundary - -IntentCI v1 should own: - -* requirement parsing; -* Intent IR; -* obligation semantics; -* verification planning; -* provider contracts; -* evidence normalization; -* evidence hashing; -* verdict aggregation; -* change-impact selection; -* repair packets; -* bounded repair control; -* reporting. - -IntentCI v1 should integrate: - -* test runners; -* static-analysis tools; -* security scanners; -* policy engines; -* CI systems; -* coding agents; -* existing report formats. - -IntentCI v1 should not own: - -* test execution semantics beyond process coordination; -* a custom policy language; -* a custom CI runner; -* an agent framework; -* a hosted observability backend; -* distributed workflow durability; -* automatic requirement generation. - -The v1 design rule is: - -> IntentCI owns what must be proven, what the evidence means, and whether the result satisfies the declared intent. External tools perform the underlying work.