From cfb25a2ba7f62591527d3c57dee0ee1fe6ed1c09 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Wed, 1 Jul 2026 06:31:38 +0000 Subject: [PATCH 1/3] ci: fix slopcop coverage reporting Run SlopCop coverage fixture collection in child Ruby processes so the gem tests remain visible to the SimpleCov harness, and disable Codecov auto-search for explicit coverage uploads. Co-authored-by: Codex --- .github/workflows/ci.yml | 16 ++++++++++++ gems/slopcop/test/classifier_test.rb | 18 ++++--------- gems/slopcop/test/decomplex_verdict_test.rb | 11 +++----- gems/slopcop/test/rollup_test.rb | 10 +++---- .../test/support/branch_resultset_helper.rb | 26 +++++++++++++++++++ 5 files changed, 55 insertions(+), 26 deletions(-) create mode 100644 gems/slopcop/test/support/branch_resultset_helper.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 618f405fe..a6fd9643c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./coverage/coverage.xml + disable_search: true flags: ruby fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -158,6 +159,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: gems/boobytrap/coverage.txt + disable_search: true flags: go fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -206,6 +208,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./coverage/ruby-gems/coverage.xml + disable_search: true flags: gems fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -273,6 +276,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./tmp/zig-mutants-coverage/cobertura.xml + disable_search: true flags: zig-mutants fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -302,6 +306,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./tmp/lineage-rust-coverage/cobertura.xml + disable_search: true flags: lineage-rust fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -363,6 +368,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./tmp/fact-mine-rust-coverage/cobertura.xml + disable_search: true flags: fact-mine-rust fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -404,6 +410,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./tmp/decomplex-rust-coverage/cobertura.xml + disable_search: true flags: decomplex-rust fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -542,6 +549,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./coverage/coverage.xml + disable_search: true flags: ruby fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -605,6 +613,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./coverage/coverage.xml + disable_search: true flags: ruby,transpile-tests fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -617,6 +626,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./zig/zig-out/coverage-transpile-tests/merged/kcov-merged/cobertura.xml + disable_search: true flags: zig,transpile-tests fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -700,6 +710,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./coverage/coverage.xml + disable_search: true flags: ruby,examples-benchmarks,examples-benchmarks-shard-${{ matrix.shard }} fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -712,6 +723,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./zig/zig-out/coverage-examples-benchmarks/merged/kcov-merged/cobertura.xml + disable_search: true flags: zig,examples-benchmarks,examples-benchmarks-shard-${{ matrix.shard }} fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -781,6 +793,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./coverage/coverage.xml + disable_search: true flags: ruby,fuzz,fuzz-shard-${{ matrix.shard }} fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -793,6 +806,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./zig/zig-out/coverage-fuzz/merged/kcov-merged/cobertura.xml + disable_search: true flags: zig,fuzz,fuzz-shard-${{ matrix.shard }} fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -880,6 +894,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./coverage/coverage.xml + disable_search: true flags: ruby,bc-lower,bc-lower-shard-${{ matrix.shard }} fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} @@ -1271,6 +1286,7 @@ jobs: - uses: codecov/codecov-action@v5 with: files: ./zig/zig-out/coverage/merged/kcov-merged/cobertura.xml + disable_search: true flags: zig fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} diff --git a/gems/slopcop/test/classifier_test.rb b/gems/slopcop/test/classifier_test.rb index 3d63a1f91..1733689af 100644 --- a/gems/slopcop/test/classifier_test.rb +++ b/gems/slopcop/test/classifier_test.rb @@ -3,12 +3,14 @@ require "minitest/autorun" require "tempfile" require "json" -require "coverage" require "tmpdir" require "fileutils" require_relative "../lib/slopcop" +require_relative "support/branch_resultset_helper" class ClassifierTest < Minitest::Test + include BranchResultsetHelper + C = SlopCop::Classifier def with_env(key, value) @@ -108,13 +110,8 @@ def shape(x, n) f = Tempfile.new(["cov", ".rb"]) f.write(src) f.close - Coverage.start(branches: true) - load f.path - res = Coverage.result - rs = { "T" => { "coverage" => { f.path => { "branches" => res.dig(f.path, :branches) } } } } rsf = Tempfile.new(["rs", ".json"]) - rsf.write(JSON.dump(rs)) - rsf.close + write_branch_resultset(f.path, rsf.path) arms = C.classify_file(rsf.path, f.path) cats = arms.map(&:category) @@ -144,13 +141,8 @@ def shape(n) f = Tempfile.new(["cov", ".rb"]) f.write(src) f.close - Coverage.start(branches: true) - load f.path - res = Coverage.result - rs = { "T" => { "coverage" => { f.path => { "branches" => res.dig(f.path, :branches) } } } } rsf = Tempfile.new(["rs", ".json"]) - rsf.write(JSON.dump(rs)) - rsf.close + write_branch_resultset(f.path, rsf.path) default_cats = C.classify_file(rsf.path, f.path).map(&:category) custom_cats = C.classify_file(rsf.path, f.path, diff --git a/gems/slopcop/test/decomplex_verdict_test.rb b/gems/slopcop/test/decomplex_verdict_test.rb index e5ea51225..ec8e1f458 100644 --- a/gems/slopcop/test/decomplex_verdict_test.rb +++ b/gems/slopcop/test/decomplex_verdict_test.rb @@ -4,9 +4,9 @@ require "tmpdir" require "tempfile" require "json" -require "coverage" require "fileutils" require_relative "../lib/slopcop" +require_relative "support/branch_resultset_helper" # SlopCop's OPTIONAL decomplex consumer: the negative `spurious` # filter (redundant decision -> refactor, not test) and the positive @@ -14,6 +14,8 @@ # consumer discipline: read decomplex's verdict, re-derive nothing, # degrade cleanly if absent. class DecomplexVerdictTest < Minitest::Test + include BranchResultsetHelper + # ---- adapter unit ------------------------------------------------- def write(src) @@ -175,13 +177,8 @@ def with_repo(src) system("git", "-C", dir, "config", "user.name", "t") system("git", "-C", dir, "add", "-A", out: File::NULL, err: File::NULL) system("git", "-C", dir, "commit", "-qm", "x", out: File::NULL, err: File::NULL) - Coverage.start(branches: true) - load path - res = Coverage.result rsf = "#{dir}/rs.json" - File.write(rsf, JSON.dump( - "T" => { "coverage" => { path => { "branches" => res.dig(path, :branches) } } } - )) + write_branch_resultset(path, rsf) yield dir, rsf end end diff --git a/gems/slopcop/test/rollup_test.rb b/gems/slopcop/test/rollup_test.rb index 7202c7e3d..7c0fbd03f 100644 --- a/gems/slopcop/test/rollup_test.rb +++ b/gems/slopcop/test/rollup_test.rb @@ -3,11 +3,13 @@ require "minitest/autorun" require "tmpdir" require "json" -require "coverage" require "fileutils" require_relative "../lib/slopcop" +require_relative "support/branch_resultset_helper" class RollupTest < Minitest::Test + include BranchResultsetHelper + def test_rollup_categorizes_and_surfaces_genuine_with_churn_overlay Dir.mktmpdir do |dir| FileUtils.mkdir_p("#{dir}/src") @@ -33,12 +35,8 @@ def shape(x, n) system("git", "-C", dir, "add", "-A", out: File::NULL, err: File::NULL) system("git", "-C", dir, "commit", "-qm", "add", out: File::NULL, err: File::NULL) - Coverage.start(branches: true) - load path - res = Coverage.result - rs = { "T" => { "coverage" => { path => { "branches" => res.dig(path, :branches) } } } } rsf = "#{dir}/.resultset.json" - File.write(rsf, JSON.dump(rs)) + write_branch_resultset(path, rsf) out = SlopCop::Rollup.run(files: ["src/m.rb"], repo: dir, resultset: rsf) assert out[:per_file].key?("src/m.rb") diff --git a/gems/slopcop/test/support/branch_resultset_helper.rb b/gems/slopcop/test/support/branch_resultset_helper.rb new file mode 100644 index 000000000..c4471d48d --- /dev/null +++ b/gems/slopcop/test/support/branch_resultset_helper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "open3" +require "rbconfig" + +module BranchResultsetHelper + def write_branch_resultset(source_path, resultset_path) + script = <<~'RUBY' + require "coverage" + require "json" + + source_path = ARGV.fetch(0) + Coverage.start(branches: true) + load source_path + result = Coverage.result + branches = result.dig(source_path, :branches) || {} + print JSON.dump("T" => { "coverage" => { source_path => { "branches" => branches } } }) + RUBY + + out, err, status = Open3.capture3(RbConfig.ruby, "-e", script, source_path) + raise "branch coverage fixture failed: #{err}" unless status.success? + + File.write(resultset_path, out) + resultset_path + end +end From 58b0800966bcb35ea82376bacaaed568897346b2 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Wed, 1 Jul 2026 08:09:16 +0000 Subject: [PATCH 2/3] Harden gem coverage reporting and tests Co-authored-by: Codex --- .github/workflows/ci.yml | 40 +- codecov.yml | 10 + gems/boobytrap/src/main_test.go | 1493 ++++++++++++- .../commands/collect_runtime_command.rb | 2 +- gems/nil-kill/lib/nil_kill/detector_runner.rb | 117 - .../lib/nil_kill/native/state_writes.rb | 43 - gems/nil-kill/lib/nil_kill/runtime_trace.rb | 20 +- gems/nil-kill/spec/coverage_hardening_spec.rb | 1874 +++++++++++++++++ .../spec/hidden_enum_pressure_spec.rb | 120 ++ gems/nil-kill/spec/support/mini_collect.rb | 5 +- .../spec/support/subprocess_coverage.rb | 23 + gems/nil-kill/src/actions.rs | 499 +++++ gems/nil-kill/src/main.rs | 4 +- gems/nil-kill/src/schemas.rs | 5 +- gems/nil-kill/tests/cli.rs | 51 + gems/slopcop/test/hardening_test.rb | 515 +++++ tools/run_ruby_gem_coverage.rb | 22 + 17 files changed, 4669 insertions(+), 174 deletions(-) delete mode 100644 gems/nil-kill/lib/nil_kill/detector_runner.rb delete mode 100644 gems/nil-kill/lib/nil_kill/native/state_writes.rb create mode 100644 gems/nil-kill/spec/coverage_hardening_spec.rb create mode 100644 gems/nil-kill/spec/support/subprocess_coverage.rb create mode 100644 gems/nil-kill/tests/cli.rs create mode 100644 gems/slopcop/test/hardening_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6fd9643c..b17797a86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,8 +154,12 @@ jobs: node-version: 20 - name: Install Tree-sitter grammars run: npm install --legacy-peer-deps - - run: bundle exec go test -v -coverprofile=coverage.txt -covermode=atomic ./... + - run: bundle exec go test -v -coverprofile=coverage.raw.txt -covermode=atomic ./... working-directory: gems/boobytrap + - name: Normalize Boobytrap Go coverage paths + run: | + ruby -e 'ARGV.each { |path| text = File.read(path); File.write(path.sub(".raw.", "."), text.gsub(/^boobytrap-helper\//, "gems/boobytrap/")) }' \ + gems/boobytrap/coverage.raw.txt - uses: codecov/codecov-action@v5 with: files: gems/boobytrap/coverage.txt @@ -373,6 +377,40 @@ jobs: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} + nil-kill-rust-coverage: + name: Nil-Kill Rust coverage + needs: changes + if: ${{ needs.changes.outputs.run_gems == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Z3 + run: | + sudo apt-get update + sudo apt-get install -y z3 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-llvm-cov + - name: Run cargo llvm-cov for Nil-Kill + run: | + mkdir -p tmp/nil-kill-rust-coverage + cargo llvm-cov \ + --manifest-path gems/nil-kill/Cargo.toml \ + --all-features \ + --cobertura \ + --output-path ${{ github.workspace }}/tmp/nil-kill-rust-coverage/cobertura.xml + - uses: actions/upload-artifact@v4 + with: + name: nil-kill-rust-coverage + path: tmp/nil-kill-rust-coverage/cobertura.xml + retention-days: 7 + - uses: codecov/codecov-action@v5 + with: + files: ./tmp/nil-kill-rust-coverage/cobertura.xml + disable_search: true + flags: nil-kill-rust + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + decomplex-rust-coverage: name: Decomplex Rust coverage diff --git a/codecov.yml b/codecov.yml index 383ca10e4..f1666e3d5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -55,11 +55,21 @@ flags: - gems/lineage/ carryforward: false joined: false + go: + paths: + - gems/boobytrap/ + carryforward: false + joined: false gems: paths: - gems/ carryforward: false joined: false + nil-kill-rust: + paths: + - gems/nil-kill/ + carryforward: false + joined: false zig-mutants: paths: - gems/zig-mutants/ diff --git a/gems/boobytrap/src/main_test.go b/gems/boobytrap/src/main_test.go index 49f6e450d..45bb99dfc 100644 --- a/gems/boobytrap/src/main_test.go +++ b/gems/boobytrap/src/main_test.go @@ -28,7 +28,7 @@ func TestRelpath(t *testing.T) { {"/a/b/c", "/a/b", "c"}, {"/a/b", "/a/b", ""}, {"/x/y", "/a/b", "../../x/y"}, // non-relative fallback - {"c", "/a/b", "c"}, // relative vs absolute error case + {"c", "/a/b", "c"}, // relative vs absolute error case } for _, tc := range tests { got := relpath(tc.abs, tc.root) @@ -329,6 +329,370 @@ func TestProcessCoverageXML(t *testing.T) { } } +func TestProcessCoverageNilKillJsonlMergesRuntimeLines(t *testing.T) { + dir := t.TempDir() + writeTempFile(t, dir, "src/demo.rb", "def call\n maybe\nend\n") + covFile := filepath.Join(dir, "runtime.jsonl") + body := strings.Join([]string{ + `{"path":"src/demo.rb","lines":[1,3]}`, + `{"path":"src/demo.rb","lines":{"2":0,"3":2,"bogus":7}}`, + `{"path":"","lines":[10]}`, + "", + }, "\n") + if err := os.WriteFile(covFile, []byte(body), 0644); err != nil { + t.Fatalf("failed to write nil-kill jsonl: %v", err) + } + + dataset, gaps, err := processCoverage(covFile, dir) + if err != nil { + t.Fatalf("processCoverage failed for nil-kill jsonl: %v", err) + } + fc := mustCoverageFile(t, dataset, filepath.Join(dir, "src/demo.rb")) + assertLineHits(t, fc.Lines, []int{1, 0, 3}) + if fc.Format != "nil_kill_jsonl" { + t.Fatalf("format = %q; want nil_kill_jsonl", fc.Format) + } + if fc.Language != "ruby" { + t.Fatalf("language = %q; want ruby", fc.Language) + } + if len(gaps) != 0 { + t.Fatalf("jsonl line coverage should not synthesize branch gaps: %+v", gaps) + } +} + +func TestProcessCoverageNilKillBranchCoverageNormalizesPathsAndArms(t *testing.T) { + dir := t.TempDir() + writeTempFile(t, dir, "src/alpha.rb", "if value\n one\nelse\n two\nend\n") + covFile := filepath.Join(dir, "branch.json") + body := `{ + "format": "nil-kill.branch-coverage", + "root": "src", + "files": [ + { + "path": "alpha.rb", + "lines": {"1": 1, "3": 0}, + "arms": [ + { + "branch_id": "b1", + "arm_id": "then", + "kind": "if", + "label": "then", + "decision_span": [1, 1, 5, 4], + "arm_span": [2, 3, 2, 5], + "hits": 4 + }, + { + "branch_id": "b1", + "arm_id": "else", + "arm": "else", + "decision_span": [1, 1, 5, 4], + "span": [4, 3, 4, 5], + "count": 0 + }, + { + "branch_id": "bad", + "arm_id": "skip", + "decision_span": [1, 1], + "arm_span": [4, 3, 4, 5], + "sample_count": 9 + } + ] + }, + {"filename": "", "lines": [99]} + ] + }` + if err := os.WriteFile(covFile, []byte(body), 0644); err != nil { + t.Fatalf("failed to write nil-kill branch coverage: %v", err) + } + + dataset, gaps, err := processCoverage(covFile, dir) + if err != nil { + t.Fatalf("processCoverage failed for nil-kill branch coverage: %v", err) + } + fc := mustCoverageFile(t, dataset, filepath.Join(dir, "src/alpha.rb")) + assertLineHits(t, fc.Lines, []int{1, -1, 0}) + if fc.Format != "nil_kill_branch" { + t.Fatalf("format = %q; want nil_kill_branch", fc.Format) + } + if fc.Language != "ruby" { + t.Fatalf("language = %q; want ruby", fc.Language) + } + if len(fc.BranchArms) != 2 { + t.Fatalf("branch arms = %+v; want 2 valid arms", fc.BranchArms) + } + if fc.BranchArms[0].Kind != "if" || fc.BranchArms[0].Member != "then" || fc.BranchArms[0].Hits != 4 { + t.Fatalf("then arm mismatch: %+v", fc.BranchArms[0]) + } + if fc.BranchArms[1].Kind != "branch" || fc.BranchArms[1].Member != "else" || fc.BranchArms[1].Hits != 0 { + t.Fatalf("else arm mismatch: %+v", fc.BranchArms[1]) + } + gap, ok := gaps["src/alpha.rb"] + if !ok { + t.Fatalf("missing branch gap for src/alpha.rb: %+v", gaps) + } + if gap.Total != 2 || gap.Uncovered != 1 || gap.Gap != 0.5 { + t.Fatalf("gap = %+v; want one miss out of two", gap) + } +} + +func TestProcessCoverageKcovCodecovMergesHits(t *testing.T) { + dir := t.TempDir() + writeTempFile(t, dir, "src/covered.zig", "pub fn main() void {}\n") + covFile := filepath.Join(dir, "codecov.json") + body := `{ + "coverage": { + "src/covered.zig": {"1": 2, "2": 0, "bad": 9}, + "/does/not/exist.zig": {"1": 1} + } + }` + if err := os.WriteFile(covFile, []byte(body), 0644); err != nil { + t.Fatalf("failed to write kcov codecov JSON: %v", err) + } + + dataset, _, err := processCoverage(covFile, dir) + if err != nil { + t.Fatalf("processCoverage failed for kcov codecov JSON: %v", err) + } + fc := mustCoverageFile(t, dataset, filepath.Join(dir, "src/covered.zig")) + assertLineHits(t, fc.Lines, []int{2, 0}) + if fc.Format != "kcov_codecov" { + t.Fatalf("format = %q; want kcov_codecov", fc.Format) + } + if fc.Language != "zig" { + t.Fatalf("language = %q; want zig", fc.Language) + } +} + +func TestProcessCoveragePythonCoverageJsonMapsArcsThroughSyntaxFacts(t *testing.T) { + dir := t.TempDir() + writeTempFile(t, dir, "pkg/sample.py", "if ready:\n one()\nelse:\n two()\n") + decomplex := filepath.Join(dir, "fake-decomplex") + script := `#!/bin/sh +cat <<'JSON' +{"documents":[{"branch_arms":[ +{"branch_id":"if:1","arm_id":"then","kind":"if","member":"then","decision_line":1,"decision_span":[1,1,4,8],"line":2,"span":[2,1,2,12]}, +{"branch_id":"if:1","arm_id":"else","kind":"if","member":"else","decision_line":1,"decision_span":[1,1,4,8],"line":4,"span":[4,1,4,12]}, +{"branch_id":"if:2","arm_id":"unrelated","kind":"if","member":"then","decision_line":9,"decision_span":[9,1,10,8],"line":10,"span":[10,1,10,12]} +]}]} +JSON +` + if err := os.WriteFile(decomplex, []byte(script), 0755); err != nil { + t.Fatalf("failed to write fake decomplex: %v", err) + } + t.Setenv("DECOMPLEX_RUST_BINARY", decomplex) + + covFile := filepath.Join(dir, "coverage-py.json") + body := `{ + "files": { + "pkg/sample.py": { + "executed_lines": [1, 2, 0], + "missing_lines": [3, 4, -1], + "executed_branches": [[1, 2], [0, 9], [1]], + "missing_branches": [[1, 4]] + }, + "pkg/missing.py": { + "executed_lines": [1], + "missing_lines": [], + "executed_branches": [], + "missing_branches": [] + } + } + }` + if err := os.WriteFile(covFile, []byte(body), 0644); err != nil { + t.Fatalf("failed to write coverage.py JSON: %v", err) + } + + dataset, gaps, err := processCoverage(covFile, dir) + if err != nil { + t.Fatalf("processCoverage failed for coverage.py JSON: %v", err) + } + fc := mustCoverageFile(t, dataset, filepath.Join(dir, "pkg/sample.py")) + assertLineHits(t, fc.Lines, []int{1, 1, 0, 0}) + if fc.Format != "coverage_py" { + t.Fatalf("format = %q; want coverage_py", fc.Format) + } + if len(fc.BranchArms) != 2 { + t.Fatalf("branch arms = %+v; want mapped then/else arms", fc.BranchArms) + } + if fc.BranchArms[0].ArmID != "then" || fc.BranchArms[0].Hits != 1 { + t.Fatalf("then arm mismatch: %+v", fc.BranchArms[0]) + } + if fc.BranchArms[1].ArmID != "else" || fc.BranchArms[1].Hits != 0 { + t.Fatalf("else arm mismatch: %+v", fc.BranchArms[1]) + } + gap := gaps["pkg/sample.py"] + if gap.Total != 2 || gap.Uncovered != 1 || gap.Gap != 0.5 { + t.Fatalf("gap = %+v; want one missing Python branch arm", gap) + } + if _, ok := dataset.Files[filepath.Join(dir, "pkg/missing.py")]; ok { + t.Fatalf("coverage for non-existent Python source should be ignored") + } +} + +func TestCoverageParsersRejectMalformedInputsAndSkipInvalidShapes(t *testing.T) { + dir := t.TempDir() + if err := parseNilKillJsonl([]byte("{not-json}\n"), dir, map[string]FileCoverage{}); err == nil { + t.Fatalf("malformed nil-kill jsonl should fail") + } + if err := parseKcovCodecov([]byte("{not-json}"), dir, map[string]FileCoverage{}); err == nil { + t.Fatalf("malformed kcov codecov JSON should fail") + } + if err := parseNilKillBranchCoverage([]byte("{not-json}"), dir, map[string]FileCoverage{}); err == nil { + t.Fatalf("malformed nil-kill branch coverage should fail") + } + if err := parsePythonCoverageJson([]byte("{not-json}"), dir, map[string]FileCoverage{}); err == nil { + t.Fatalf("malformed coverage.py JSON should fail") + } + + badXML := filepath.Join(dir, "bad.xml") + if err := os.WriteFile(badXML, []byte(""), 0644); err != nil { + t.Fatalf("failed to write bad xml: %v", err) + } + if _, _, err := processCoverage(badXML, dir); err == nil { + t.Fatalf("malformed cobertura XML should fail") + } + + xmlFile := filepath.Join(dir, "fallback.xml") + xmlContent := ` + + + + + + + + + + + + + + + + + +` + if err := os.WriteFile(xmlFile, []byte(xmlContent), 0644); err != nil { + t.Fatalf("failed to write fallback xml: %v", err) + } + dataset, _, err := processCoverage(string(filepath.ListSeparator)+xmlFile, dir) + if err != nil { + t.Fatalf("fallback XML coverage failed: %v", err) + } + fc := mustCoverageFile(t, dataset, filepath.Join(dir, "ghost.py")) + assertLineHits(t, fc.Lines, []int{-1, 5}) + if fc.SourcePath != "ghost.py" || fc.Language != "python" { + t.Fatalf("fallback XML metadata mismatch: %+v", fc) + } + + writeTempFile(t, dir, "simple.rb", "def call\nend\n") + simpleCov := filepath.Join(dir, "simplecov.json") + body := `{ + "bad-entry": "skip", + "missing-coverage": {}, + "bad-coverage": {"coverage": "skip"}, + "RSpec": { + "coverage": { + "bad-file": "skip", + "simple.rb": { + "lines": [1, null], + "branches": {"bad": "skip"} + } + } + } + }` + if err := os.WriteFile(simpleCov, []byte(body), 0644); err != nil { + t.Fatalf("failed to write simplecov JSON: %v", err) + } + dataset, _, err = processCoverage(simpleCov, dir) + if err != nil { + t.Fatalf("simplecov edge coverage failed: %v", err) + } + fc = mustCoverageFile(t, dataset, filepath.Join(dir, "simple.rb")) + assertLineHits(t, fc.Lines, []int{1, -1}) + if fc.Format != "simplecov" || fc.Language != "ruby" { + t.Fatalf("simplecov metadata mismatch: %+v", fc) + } + + merged := mergeBranches(nil, map[string]interface{}{ + "branch": map[string]interface{}{"arm": 2.0, "bad": "skip"}, + "skip": "not arms", + }) + if merged["branch"]["arm"] != 2 { + t.Fatalf("mergeBranches nil target mismatch: %+v", merged) + } +} + +func TestCoverageFormatDetectorsAndLanguageHelpers(t *testing.T) { + for _, tc := range []struct { + path string + want string + }{ + {"app.rb", "ruby"}, + {"script.PY", "python"}, + {"view.jsx", "javascript"}, + {"worker.cjs", "javascript"}, + {"types.tsx", "typescript"}, + {"main.go", "go"}, + {"lib.rs", "rust"}, + {"kernel.zig", "zig"}, + {"api.h", "c"}, + {"api.hxx", "cpp"}, + {"Program.cs", "csharp"}, + {"Thing.java", "java"}, + {"build.kts", "kotlin"}, + {"ios.swift", "swift"}, + {"index.php", "php"}, + {"mod.lua", "lua"}, + {"README", ""}, + } { + if got := detectLanguage(tc.path); got != tc.want { + t.Fatalf("detectLanguage(%q) = %q; want %q", tc.path, got, tc.want) + } + } + + for _, tc := range []struct { + file string + want string + }{ + {"main.rb", "ruby"}, + {"tool.py", "python"}, + {"app.js", "javascript"}, + {"types.ts", "typescript"}, + {"main.go", "go"}, + {"lib.rs", "rust"}, + {"mod.zig", "zig"}, + {"core.c", "c"}, + {"core.cpp", "cpp"}, + {"Program.cs", "csharp"}, + {"build.kt", "kotlin"}, + {"README", "generic"}, + } { + if got := languageFor(tc.file); got != tc.want { + t.Fatalf("languageFor(%q) = %q; want %q", tc.file, got, tc.want) + } + } + + pythonCoverage := map[string]interface{}{ + "files": map[string]interface{}{ + "sample.py": map[string]interface{}{"missing_branches": []interface{}{}}, + }, + } + if !isPythonCoverageJson(pythonCoverage) { + t.Fatalf("coverage.py shape was not detected") + } + for _, raw := range []map[string]interface{}{ + {}, + {"files": "not a map"}, + {"files": map[string]interface{}{"sample.py": "not a file entry"}}, + {"files": map[string]interface{}{"sample.py": map[string]interface{}{"summary": 1}}}, + } { + if isPythonCoverageJson(raw) { + t.Fatalf("non coverage.py shape detected as coverage.py: %+v", raw) + } + } +} + func TestMainFunction(t *testing.T) { dir := t.TempDir() runCmd(t, dir, "git", "init", "-q") @@ -391,6 +755,159 @@ func TestMainFunctionExits(t *testing.T) { } } +func TestMainFunctionParseCoverageOnlyAndHelp(t *testing.T) { + if os.Getenv("BE_PARSE_COVERAGE_ONLY") == "1" { + caseType := os.Getenv("PARSE_COVERAGE_CASE") + switch caseType { + case "success": + os.Args = []string{ + "cmd", + "--repo=" + os.Getenv("TEST_REPO"), + "--coverage=" + os.Getenv("TEST_COVERAGE"), + "--parse-coverage-only", + } + case "missing-coverage": + os.Args = []string{ + "cmd", + "--repo=" + os.Getenv("TEST_REPO"), + "--parse-coverage-only", + } + case "invalid-coverage": + os.Args = []string{ + "cmd", + "--repo=" + os.Getenv("TEST_REPO"), + "--coverage=" + filepath.Join(os.Getenv("TEST_REPO"), "missing.json"), + "--parse-coverage-only", + } + case "help": + os.Args = []string{"cmd", "--help"} + } + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + main() + return + } + + dir := t.TempDir() + writeTempFile(t, dir, "a.go", "package main\n") + covData := map[string]interface{}{ + "RSpec": map[string]interface{}{ + "coverage": map[string]interface{}{ + filepath.Join(dir, "a.go"): map[string]interface{}{ + "lines": []interface{}{1.0}, + }, + }, + }, + } + covBytes, _ := json.Marshal(covData) + covFile := filepath.Join(dir, "coverage.json") + if err := os.WriteFile(covFile, covBytes, 0644); err != nil { + t.Fatalf("failed to write coverage json: %v", err) + } + + success := exec.Command(os.Args[0], "-test.run=TestMainFunctionParseCoverageOnlyAndHelp") + success.Env = append(os.Environ(), + "BE_PARSE_COVERAGE_ONLY=1", + "PARSE_COVERAGE_CASE=success", + "TEST_REPO="+dir, + "TEST_COVERAGE="+covFile, + ) + out, err := success.CombinedOutput() + if err != nil { + t.Fatalf("parse coverage only failed: %v, output: %s", err, string(out)) + } + if !strings.Contains(string(out), `"source_path":"a.go"`) { + t.Fatalf("parse coverage output did not include normalized source path: %s", string(out)) + } + + for _, caseType := range []string{"missing-coverage", "invalid-coverage"} { + cmd := exec.Command(os.Args[0], "-test.run=TestMainFunctionParseCoverageOnlyAndHelp") + cmd.Env = append(os.Environ(), + "BE_PARSE_COVERAGE_ONLY=1", + "PARSE_COVERAGE_CASE="+caseType, + "TEST_REPO="+dir, + "TEST_COVERAGE="+covFile, + ) + err := cmd.Run() + if e, ok := err.(*exec.ExitError); ok && !e.Success() { + continue + } + t.Fatalf("case %s ran with err %v; want non-zero exit", caseType, err) + } + + help := exec.Command(os.Args[0], "-test.run=TestMainFunctionParseCoverageOnlyAndHelp") + help.Env = append(os.Environ(), + "BE_PARSE_COVERAGE_ONLY=1", + "PARSE_COVERAGE_CASE=help", + ) + helpOut, err := help.CombinedOutput() + if err != nil { + t.Fatalf("help command failed: %v, output: %s", err, string(helpOut)) + } + if !strings.Contains(string(helpOut), "Usage: boobytrap report [options]") { + t.Fatalf("help output mismatch: %s", string(helpOut)) + } +} + +func TestMainFunctionInputAndReportWriteErrorsExit(t *testing.T) { + if os.Getenv("BE_INPUT_ERROR") == "1" { + repo := os.Getenv("TEST_REPO") + caseType := os.Getenv("INPUT_ERROR_CASE") + switch caseType { + case "invalid-decomplex": + os.Args = []string{"cmd", "--repo=" + repo, "--decomplex-facts=" + filepath.Join(repo, "missing-decomplex.json")} + case "invalid-mutation": + os.Args = []string{"cmd", "--repo=" + repo, "--mutation=" + filepath.Join(repo, "missing-mutation.json")} + case "invalid-test-exposure": + os.Args = []string{"cmd", "--repo=" + repo, "--test-exposure=" + filepath.Join(repo, "missing-exposure.json")} + case "invalid-static": + os.Args = []string{"cmd", "--repo=" + repo, "--static-files-file=" + filepath.Join(repo, "missing-static.json")} + case "report-source-scan-error": + os.Args = []string{"cmd", "report", "--repo=" + repo, "--coverage=", "--output=" + filepath.Join(repo, "report.md")} + case "report-output-error": + os.Args = []string{"cmd", "report", "--repo=" + repo, "--coverage=", "--output=" + repo} + case "report-json-error": + os.Args = []string{"cmd", "report", "--repo=" + repo, "--coverage=", "--json=" + repo} + } + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + main() + return + } + + dir := t.TempDir() + runCmd(t, dir, "git", "init", "-q") + runCmd(t, dir, "git", "config", "user.email", "test@test.com") + runCmd(t, dir, "git", "config", "user.name", "test") + writeTempFile(t, dir, "a.go", "package main\nfunc a() int { return 1 }\n") + runCmd(t, dir, "git", "add", "a.go") + runCmd(t, dir, "git", "commit", "-qm", "Fix issue") + + for _, caseType := range []string{ + "invalid-decomplex", + "invalid-mutation", + "invalid-test-exposure", + "invalid-static", + "report-source-scan-error", + "report-output-error", + "report-json-error", + } { + cmd := exec.Command(os.Args[0], "-test.run=TestMainFunctionInputAndReportWriteErrorsExit") + env := append(os.Environ(), + "BE_INPUT_ERROR=1", + "INPUT_ERROR_CASE="+caseType, + "TEST_REPO="+dir, + ) + if caseType == "report-source-scan-error" { + env = append(env, "BUNDLE_GEMFILE="+filepath.Join(dir, "missing-Gemfile")) + } + cmd.Env = env + err := cmd.Run() + if e, ok := err.(*exec.ExitError); ok && !e.Success() { + continue + } + t.Fatalf("case %s ran with err %v; want non-zero exit", caseType, err) + } +} + func runCmd(t *testing.T, dir string, name string, args ...string) { cmd := exec.Command(name, args...) cmd.Dir = dir @@ -401,11 +918,983 @@ func runCmd(t *testing.T, dir string, name string, args ...string) { func writeTempFile(t *testing.T, dir, filename, content string) { path := filepath.Join(dir, filename) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create temp file directory: %v", err) + } if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("failed to write temp file: %v", err) } } +func mustCoverageFile(t *testing.T, dataset *CoverageDataset, path string) FileCoverage { + t.Helper() + fc, ok := dataset.Files[filepath.Clean(path)] + if !ok { + t.Fatalf("missing coverage for %s; files=%v", path, dataset.Files) + } + return fc +} + +func assertLineHits(t *testing.T, got []*int, want []int) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("line count = %d; want %d; got %+v", len(got), len(want), got) + } + for idx, expected := range want { + if expected < 0 { + if got[idx] != nil { + t.Fatalf("line %d = %d; want nil", idx+1, *got[idx]) + } + continue + } + if got[idx] == nil { + t.Fatalf("line %d is nil; want %d", idx+1, expected) + } + if *got[idx] != expected { + t.Fatalf("line %d = %d; want %d", idx+1, *got[idx], expected) + } + } +} + +func TestParseHelperEdgeCases(t *testing.T) { + if got := parseInt(nil); got != 0 { + t.Fatalf("parseInt(nil) = %d; want 0", got) + } + if got := parseInt("7"); got != 0 { + t.Fatalf("parseInt(non-number) = %d; want 0", got) + } + if got := parseInt(3.9); got != 3 { + t.Fatalf("parseInt(float) = %d; want truncation to 3", got) + } + if got := parseIntList("not a list"); got != nil { + t.Fatalf("parseIntList(non-list) = %+v; want nil", got) + } + if got := parseIntList([]interface{}{1.0, "skip", 3.0}); len(got) != 2 || got[0] != 1 || got[1] != 3 { + t.Fatalf("parseIntList mixed values = %+v; want [1 3]", got) + } + if got := parseNilKillLines("bad"); got != nil { + t.Fatalf("parseNilKillLines(non-shape) = %+v; want nil", got) + } + if lineInSpan(2, []int{1, 2}) { + t.Fatalf("short span should not match") + } +} + +func TestFindDecomplexBinaryPrefersConfiguredAndLocalBuilds(t *testing.T) { + dir := t.TempDir() + t.Run("env", func(t *testing.T) { + t.Setenv("DECOMPLEX_RUST_BINARY", filepath.Join(dir, "custom-decomplex")) + if got := findDecomplexBinary(dir); got != filepath.Join(dir, "custom-decomplex") { + t.Fatalf("env decomplex = %q", got) + } + }) + t.Run("release", func(t *testing.T) { + t.Setenv("DECOMPLEX_RUST_BINARY", "") + release := filepath.Join(dir, "release-root/gems/decomplex/target/release/decomplex-rust") + writeTempFile(t, filepath.Join(dir, "release-root"), "gems/decomplex/target/release/decomplex-rust", "") + if got := findDecomplexBinary(filepath.Join(dir, "release-root")); got != release { + t.Fatalf("release decomplex = %q; want %q", got, release) + } + }) + t.Run("debug", func(t *testing.T) { + t.Setenv("DECOMPLEX_RUST_BINARY", "") + debugRoot := filepath.Join(dir, "debug-root") + debug := filepath.Join(debugRoot, "gems/decomplex/target/debug/decomplex-rust") + writeTempFile(t, debugRoot, "gems/decomplex/target/debug/decomplex-rust", "") + if got := findDecomplexBinary(debugRoot); got != debug { + t.Fatalf("debug decomplex = %q; want %q", got, debug) + } + }) + t.Run("fallback", func(t *testing.T) { + t.Setenv("DECOMPLEX_RUST_BINARY", "") + if got := findDecomplexBinary(filepath.Join(dir, "missing-root")); got != "decomplex-rust" { + t.Fatalf("fallback decomplex = %q; want decomplex-rust", got) + } + }) +} + +func TestMutationPoliciesClassifyVerificationRisk(t *testing.T) { + weakRate := 55.0 + moderateRate := 75.0 + strongRate := 95.0 + if !isMutationWeak(nil, "") || !isMutationWeak(&weakRate, "required") || !isMutationWeak(&strongRate, "advisory") { + t.Fatalf("weak mutation policy did not classify nil, low rate, and advisory gate as weak") + } + if isMutationWeak(&moderateRate, "required") { + t.Fatalf("moderate enforced mutation result should not be weak") + } + if !isMutationStrong(&strongRate, "hard-gated") || isMutationStrong(&moderateRate, "hard") || isMutationStrong(nil, "hard") { + t.Fatalf("strong mutation policy did not require both high kill rate and enforced gate") + } + + strong := &MutationFact{KillRate: &strongRate, GateStatus: "required"} + moderate := &MutationFact{KillRate: &moderateRate, GateStatus: "required"} + weak := &MutationFact{KillRate: &weakRate, GateStatus: "advisory"} + if got := mutationRiskMultiplier(strong, true, 3, 0.1, 0.2); got != 0.75 { + t.Fatalf("strong low-risk multiplier = %.2f; want 0.75", got) + } + if got := mutationRiskMultiplier(strong, true, 7, 0.8, 0.2); got != 0.9 { + t.Fatalf("strong high-history multiplier = %.2f; want 0.90", got) + } + if got := mutationRiskMultiplier(moderate, true, 3, 0.1, 0.2); got != 1.1 { + t.Fatalf("moderate multiplier = %.2f; want 1.10", got) + } + if got := mutationRiskMultiplier(weak, true, 7, 0.8, 0.9); got != 1.9 { + t.Fatalf("weak high-risk multiplier = %.2f; want 1.90", got) + } + if got := mutationRiskMultiplier(weak, true, 7, 0.1, 0.1); got != 1.45 { + t.Fatalf("weak high-complexity multiplier = %.2f; want 1.45", got) + } + if got := mutationRiskMultiplier(weak, true, 2, 0.1, 0.1); got != 1.25 { + t.Fatalf("weak low-risk multiplier = %.2f; want 1.25", got) + } + if got := mutationRiskMultiplier(weak, false, 7, 0.8, 0.9); got != 1.0 { + t.Fatalf("inactive mutation multiplier = %.2f; want 1.0", got) + } + + for _, tc := range []struct { + fact *MutationFact + complexity float64 + history float64 + gap float64 + want string + }{ + {weak, 7, 0.8, 0.9, "lurking disaster"}, + {strong, 7, 0.1, 0.1, "hardened veteran"}, + {weak, 3, 0.8, 0.1, "fragile newcomer"}, + {weak, 3, 0.1, 0.1, "weak verification"}, + {moderate, 3, 0.1, 0.1, "partial verification"}, + {strong, 3, 0.1, 0.1, "load-bearing tests"}, + } { + if got := mutationProfile(tc.fact, true, tc.complexity, tc.history, tc.gap); got != tc.want { + t.Fatalf("mutationProfile(%+v) = %q; want %q", tc.fact, got, tc.want) + } + } + if got := mutationProfile(weak, false, 7, 0.8, 0.9); got != "" { + t.Fatalf("inactive mutation profile = %q; want empty", got) + } + + facts := map[MethodKey]MutationFact{ + {File: "src/a.rb", Method: "call"}: {Method: "call"}, + {File: "src/b.rb", Method: "*"}: {Method: "*"}, + {File: "\x00global", Method: "shared"}: {Method: "shared"}, + {File: "\x00global", Method: "Namespace::build"}: {Method: "Namespace::build"}, + } + if got := lookupMutationFact(facts, "./src/a.rb", "Owner#call"); got == nil || got.Method != "call" { + t.Fatalf("alias lookup failed: %+v", got) + } + if got := lookupMutationFact(facts, "src/b.rb", "missing"); got == nil || got.Method != "*" { + t.Fatalf("file default lookup failed: %+v", got) + } + if got := lookupMutationFact(facts, "src/c.rb", "Owner.shared"); got == nil || got.Method != "shared" { + t.Fatalf("global alias lookup failed: %+v", got) + } + if got := lookupMutationFact(facts, "src/c.rb", "absent"); got != nil { + t.Fatalf("missing mutation fact = %+v; want nil", got) + } +} + +func TestTestExposurePoliciesAggregateNamedCoverage(t *testing.T) { + payload := &TestExposurePayload{ + MethodHits: []MethodHitEntry{ + {File: "src/a.rb", Method: "call", Hit: Hit{TestID: "t1", TestType: "spec", MutationStatus: "killed", Line: 1}}, + {File: "src/other.rb", Method: "call", Hit: Hit{TestID: "skip", TestType: "spec", MutationStatus: "killed", Line: 1}}, + }, + LineHits: []LineHitEntry{ + {File: "src/a.rb", Line: 2, Hit: Hit{TestID: "t2", TestType: "unit", MutationStatus: "survived", Line: 2}}, + {File: "src/a.rb", Line: 9, Hit: Hit{TestID: "outside", TestType: "unit", MutationStatus: "survived", Line: 9}}, + }, + BranchHits: []BranchHitEntry{ + {File: "src/a.rb", BranchID: "if:1", Line: 3, Hit: Hit{TestID: "t3", TestType: "spec", MutationStatus: "unverified", Line: 3, BranchID: "if:1"}}, + {File: "src/a.rb", BranchID: "", Line: 4, Hit: Hit{TestID: "t4", TestType: "", MutationStatus: "failed", Line: 4}}, + }, + } + agg := buildTestExposureAggregated(payload, "./src/a.rb", "Owner#call", 1, 5) + if len(agg.FunctionTests) != 1 || len(agg.LineTests[2]) != 1 || len(agg.LineTests[9]) != 0 { + t.Fatalf("aggregate method/line hits mismatch: %+v", agg) + } + if len(agg.BranchTests["if:1"]) != 1 || len(agg.BranchTests["line:4"]) != 1 { + t.Fatalf("aggregate branch hits mismatch: %+v", agg.BranchTests) + } + if got := testExposureSummary(agg); got != "4 tests; spec=2/unit=1/unknown=1; mutant killed 1/3; lines=1; branches=2" { + t.Fatalf("testExposureSummary = %q", got) + } + if got := testExposureMultiplier(agg, true, 1, 0, 0); got != 0.70 { + t.Fatalf("killed exposure multiplier = %.2f; want 0.70", got) + } + if got := testExposureProfile(agg, true); got != "mutation-killed exposure" { + t.Fatalf("killed exposure profile = %q", got) + } + + empty := &TestExposureAggregated{LineTests: map[int][]TestExposureHit{}, BranchTests: map[string][]TestExposureHit{}} + if got := testExposureSummary(empty); got != "no named tests" { + t.Fatalf("empty summary = %q", got) + } + if got := testExposureMultiplier(empty, true, 6, 0, 0); got != 1.15 { + t.Fatalf("empty high-risk multiplier = %.2f; want 1.15", got) + } + if got := testExposureMultiplier(empty, true, 1, 0, 0); got != 1.05 { + t.Fatalf("empty low-risk multiplier = %.2f; want 1.05", got) + } + if got := testExposureMultiplier(empty, false, 6, 0, 0); got != 1.0 { + t.Fatalf("inactive exposure multiplier = %.2f; want 1.0", got) + } + if got := testExposureProfile(empty, true); got != "unobserved by named tests" { + t.Fatalf("empty profile = %q", got) + } + if got := testExposureProfile(empty, false); got != "" { + t.Fatalf("inactive profile = %q; want empty", got) + } + + diverse := &TestExposureAggregated{ + FunctionTests: []TestExposureHit{ + {TestID: "a", TestType: "spec"}, + {TestID: "b", TestType: "unit"}, + {TestID: "c", TestType: "integration"}, + {TestID: "d", TestType: "spec"}, + {TestID: "e", TestType: "unit"}, + }, + LineTests: map[int][]TestExposureHit{}, + BranchTests: map[string][]TestExposureHit{}, + } + if got := testExposureMultiplier(diverse, true, 1, 0, 0); got != 0.80 { + t.Fatalf("diverse multiplier = %.2f; want 0.80", got) + } + if got := testExposureProfile(diverse, true); got != "diverse named coverage" { + t.Fatalf("diverse profile = %q", got) + } + + named := &TestExposureAggregated{ + FunctionTests: []TestExposureHit{{TestID: "a", TestType: "spec"}, {TestID: "b", TestType: "spec"}}, + LineTests: map[int][]TestExposureHit{}, + BranchTests: map[string][]TestExposureHit{}, + } + if got := testExposureMultiplier(named, true, 1, 0, 0); got != 0.90 { + t.Fatalf("named multiplier = %.2f; want 0.90", got) + } + if got := testExposureProfile(named, true); got != "named coverage" { + t.Fatalf("named profile = %q", got) + } + + thin := &TestExposureAggregated{ + FunctionTests: []TestExposureHit{{TestID: "a", TestType: "spec"}}, + LineTests: map[int][]TestExposureHit{}, + BranchTests: map[string][]TestExposureHit{}, + } + if got := testExposureMultiplier(thin, true, 1, 0, 0); got != 0.98 { + t.Fatalf("thin multiplier = %.2f; want 0.98", got) + } + if got := testExposureProfile(thin, true); got != "thin named coverage" { + t.Fatalf("thin profile = %q", got) + } +} + +func TestLineageExposurePoliciesRewardFreshHardening(t *testing.T) { + types := parseTestTypes("unit,spec,unit, integration ") + if strings.Join(types, ",") != "integration,spec,unit" { + t.Fatalf("parseTestTypes = %+v", types) + } + if got := lineageTestExposureStatus(LineageUnit{}); got != "" { + t.Fatalf("lineage status without tests = %q; want empty", got) + } + stale := LineageUnit{CurrentDistinctTests: 2, CurrentTestTypes: "spec", CurrentMutantVerifiedTests: 1, CurrentMutantKilledTests: 0, FixesAfterTestExposure: 3} + if got := lineageTestExposureStatus(stale); !strings.Contains(got, "ignored: 3 later fix(es)") { + t.Fatalf("stale lineage status = %q", got) + } + hardened := LineageUnit{CurrentDistinctTests: 2, CurrentTestTypes: "spec,unit", CurrentMutantVerifiedTests: 2, CurrentMutantKilledTests: 1, LatestFixAt: 10, LastTestExposureAt: 12} + if got := lineageTestExposureStatus(hardened); !strings.Contains(got, "hardened after latest fix") { + t.Fatalf("hardened lineage status = %q", got) + } + + for _, tc := range []struct { + unit LineageUnit + want string + }{ + {LineageUnit{}, ""}, + {stale, "stale lineage exposure ignored"}, + {LineageUnit{CurrentDistinctTests: 1, CurrentMutantKilledTests: 1}, "mutation-killed exposure (lineage)"}, + {LineageUnit{CurrentDistinctTests: 2, CurrentTestTypes: "spec,unit"}, "diverse named coverage (lineage)"}, + {LineageUnit{CurrentDistinctTests: 2, CurrentTestTypes: "spec"}, "named coverage (lineage)"}, + {LineageUnit{CurrentDistinctTests: 1, CurrentTestTypes: "spec"}, "thin named coverage (lineage)"}, + } { + if got := lineageExposureProfile(tc.unit); got != tc.want { + t.Fatalf("lineageExposureProfile(%+v) = %q; want %q", tc.unit, got, tc.want) + } + } + + base := LineageUnit{CurrentDistinctTests: 1, LatestFixAt: 10, LastTestExposureAt: 11} + if got := lineageExposureMultiplier(base, false, 10, 1, 1); got != 1.0 { + t.Fatalf("inactive lineage multiplier = %.2f; want 1.0", got) + } + if got := lineageExposureMultiplier(LineageUnit{}, true, 10, 1, 1); got != 1.0 { + t.Fatalf("lineage without tests multiplier = %.2f; want 1.0", got) + } + if got := lineageExposureMultiplier(stale, true, 10, 1, 1); got != 1.0 { + t.Fatalf("stale lineage multiplier = %.2f; want 1.0", got) + } + notHardened := LineageUnit{CurrentDistinctTests: 2, LatestFixAt: 10, LastTestExposureAt: 9} + if got := lineageExposureMultiplier(notHardened, true, 10, 1, 1); got != 1.0 { + t.Fatalf("not-hardened lineage multiplier = %.2f; want 1.0", got) + } + for _, tc := range []struct { + unit LineageUnit + complexity float64 + history float64 + gap float64 + want float64 + }{ + {LineageUnit{CurrentDistinctTests: 3, CurrentMutantKilledTests: 3, LatestFixAt: 10, LastTestExposureAt: 12}, 1, 0, 0, 0.45}, + {LineageUnit{CurrentDistinctTests: 3, CurrentMutantKilledTests: 1, LatestFixAt: 10, LastTestExposureAt: 12}, 1, 0, 0, 0.60}, + {LineageUnit{CurrentDistinctTests: 5, CurrentTestTypes: "spec,unit", LatestFixAt: 10, LastTestExposureAt: 12}, 1, 0, 0, 0.75}, + {LineageUnit{CurrentDistinctTests: 2, CurrentTestTypes: "spec", LatestFixAt: 10, LastTestExposureAt: 12}, 1, 0, 0, 0.88}, + {LineageUnit{CurrentDistinctTests: 1, CurrentTestTypes: "spec", LatestFixAt: 10, LastTestExposureAt: 12}, 7, 0, 0, 0.97}, + {LineageUnit{CurrentDistinctTests: 1, CurrentTestTypes: "spec", LatestFixAt: 10, LastTestExposureAt: 12}, 1, 0, 0, 1.0}, + } { + if got := lineageExposureMultiplier(tc.unit, true, tc.complexity, tc.history, tc.gap); got != tc.want { + t.Fatalf("lineageExposureMultiplier(%+v) = %.2f; want %.2f", tc.unit, got, tc.want) + } + } +} + +func TestReportFormattingPolicyHelpers(t *testing.T) { + if got := scopeLabel(nil, nil); got != "whole repo" { + t.Fatalf("whole repo scope = %q", got) + } + if got := scopeLabel([]string{"src", "lib"}, nil); got != "`src/`, `lib/`" { + t.Fatalf("only scope = %q", got) + } + if got := scopeLabel(nil, []string{"a.go", "b.go"}); got != "`a.go`, `b.go`" { + t.Fatalf("files scope = %q", got) + } + for _, tc := range []struct { + hasCoverage bool + label string + static bool + hasGaps bool + want string + }{ + {true, "coverage.xml", true, true, "coverage.xml + tree-sitter static fallback"}, + {false, "", true, true, "tree-sitter static fallback"}, + {true, "coverage.xml", false, true, "coverage.xml"}, + {false, "", false, false, "absent"}, + {false, "", false, true, "unknown"}, + } { + if got := coverageMode(tc.hasCoverage, tc.label, tc.static, tc.hasGaps); got != tc.want { + t.Fatalf("coverageMode(%+v) = %q; want %q", tc, got, tc.want) + } + } + if got := testExposureLabel(false, "facts.json"); got != "not supplied" { + t.Fatalf("inactive exposure label = %q", got) + } + if got := testExposureLabel(true, "facts.json"); got != "facts.json" { + t.Fatalf("named exposure label = %q", got) + } + if got := testExposureLabel(true, ""); got != "active" { + t.Fatalf("active exposure label = %q", got) + } + row := MethodGapRow{RiskProfile: "weak verification", TestExposureProfile: "weak verification"} + if got := empiricalProfile(row); got != "weak verification" { + t.Fatalf("deduplicated empirical profile = %q", got) + } + row.TestExposureProfile = "named coverage" + if got := empiricalProfile(row); got != "weak verification; named coverage" { + t.Fatalf("combined empirical profile = %q", got) + } + if got := empiricalProfile(MethodGapRow{}); got != "not supplied" { + t.Fatalf("empty empirical profile = %q", got) + } + if got := lineageCell(MethodGapRow{}); got != "0" { + t.Fatalf("empty lineage cell = %q", got) + } + lineage := MethodGapRow{LineageScore: 12.25, LineageFixes: 2, LineageChanges: 3, LineageMoves: 1} + if got := lineageCell(lineage); got != "12.2 (f2/c3/m1)" { + t.Fatalf("lineage cell = %q", got) + } + if got := formatFindings(nil); got != "[]" { + t.Fatalf("empty findings = %q", got) + } + if got := formatFindings([]Finding{{Type: "state"}, {Type: "branch"}}); got != `[{"type"=>"state"}, {"type"=>"branch"}]` { + t.Fatalf("findings = %q", got) + } + if got := coverageMode(false, "", false, false); got != "absent" { + t.Fatalf("coverageMode absent = %q", got) + } + if got := formatFloat(2.0, 1); got != "2.0" { + t.Fatalf("integer-ish formatFloat = %q", got) + } + if got := formatFloat(2.25, 1); got != "2.25" { + t.Fatalf("fractional formatFloat = %q", got) + } +} + +func TestSourceRangeAndRankingHelpersHandleEdgeCases(t *testing.T) { + ranges := fallbackFunctionRanges([]string{ + "noise", + "function lonely", + "def indented", + " call", + "", + "done", + }) + if len(ranges) != 2 { + t.Fatalf("fallback ranges = %+v; want lonely and indented", ranges) + } + if ranges[0].Name != "lonely" || ranges[0].FirstLine != 2 || ranges[0].LastLine != 2 { + t.Fatalf("lonely range should collapse to declaration line: %+v", ranges[0]) + } + if ranges[1].Name != "indented" || ranges[1].LastLine != 4 { + t.Fatalf("indented range mismatch: %+v", ranges[1]) + } + for _, line := range []string{"", " # comment", "// comment", "/* comment", "* comment", "end", "}", "{", ")", "("} { + if executableSourceLine(line) { + t.Fatalf("line %q should not be executable", line) + } + } + if !executableSourceLine("return value") { + t.Fatalf("normal statement should be executable") + } + if got := stateWriteCount([]string{"@count += 1", "@user.name = 'x'", "config.value << 1", "local = 1"}); got != 3 { + t.Fatalf("stateWriteCount = %d; want 3", got) + } + if aliases := methodAliases("Namespace::Owner.build"); strings.Join(aliases, ",") != "Namespace::Owner.build,build,Owner.build" { + t.Fatalf("methodAliases = %+v", aliases) + } + if got := parseBranchArmLine("not-a-simplecov-arm"); got != 0 { + t.Fatalf("invalid branch arm line = %d; want 0", got) + } + if !isHitSurvived(TestExposureHit{MutationStatus: "timedout"}) || isHitSurvived(TestExposureHit{MutationStatus: "killed"}) { + t.Fatalf("survived mutation classifier mismatch") + } + + ranked, unmeasured := rankHotspots( + map[string]float64{"b.rb": 2.0, "a.rb": 2.0, "missing-high.rb": 3.0, "missing-low.rb": 1.0}, + map[string]FileGap{ + "b.rb": {Total: 4, Uncovered: 2, Gap: 0.5}, + "a.rb": {Total: 4, Uncovered: 2, Gap: 0.5}, + }, + ) + if len(ranked) != 2 || ranked[0].File != "a.rb" || ranked[1].File != "b.rb" { + t.Fatalf("ranked hotspot tie sort mismatch: %+v", ranked) + } + if len(unmeasured) != 2 || unmeasured[0].File != "missing-high.rb" || unmeasured[1].File != "missing-low.rb" { + t.Fatalf("unmeasured sort mismatch: %+v", unmeasured) + } +} + +func TestDecomplexAndMutationInputNormalizationEdges(t *testing.T) { + dir := t.TempDir() + writeTempFile(t, dir, "src/a.rb", "def call\nend\n") + badDecomplex := filepath.Join(dir, "bad-decomplex.json") + if err := os.WriteFile(badDecomplex, []byte("{not-json}"), 0644); err != nil { + t.Fatalf("failed to write bad decomplex: %v", err) + } + if _, _, err := processDecomplexFacts(badDecomplex, dir); err == nil { + t.Fatalf("malformed decomplex facts should fail") + } + decomplex := filepath.Join(dir, "decomplex.json") + absSite := filepath.Join(dir, "src/a.rb") + ":call:12" + body := fmt.Sprintf(`{ + "detectors": { + "z_detector": {"nested": [{"sites": ["bad", %q, 7]}]}, + "a_detector": [{"sites": [%q]}], + "state_branch_density": [ + "skip", + {"file": %q, "method": "call", "score": 2.5, "decisions": 3, "state_refs": ["@x", 7], "predicate": "@x"} + ] + } + }`, absSite, absSite, filepath.Join(dir, "src/a.rb")) + if err := os.WriteFile(decomplex, []byte(body), 0644); err != nil { + t.Fatalf("failed to write decomplex facts: %v", err) + } + scores, density, err := processDecomplexFacts(decomplex, dir) + if err != nil { + t.Fatalf("processDecomplexFacts failed: %v", err) + } + if len(scores) != 1 || scores[0].File != "src/a.rb" || scores[0].Method != "call" || scores[0].Score != 2 { + t.Fatalf("decomplex scores mismatch: %+v", scores) + } + if strings.Join(scores[0].Detectors, ",") != "a_detector,z_detector" || len(scores[0].Findings) != 2 { + t.Fatalf("decomplex detector aggregation mismatch: %+v", scores[0]) + } + if len(density) != 1 || density[0].File != "src/a.rb" || density[0].Decisions != 3 || len(density[0].StateRefs) != 1 { + t.Fatalf("density rows mismatch: %+v", density) + } + + if parseKillRate(nil) != nil || parseKillRate("not-a-rate") != nil { + t.Fatalf("nil and malformed kill rates should normalize to nil") + } + if rate := parseKillRate(0.42); rate == nil || *rate != 42.0 { + t.Fatalf("fractional kill rate = %v; want 42.0", rate) + } + if got := normalizeFile("", dir); got != "" { + t.Fatalf("empty normalizeFile = %q; want empty", got) + } + if got := normalizeFile(filepath.Join(dir, "src/a.rb"), dir); got != "src/a.rb" { + t.Fatalf("absolute normalizeFile = %q; want src/a.rb", got) + } + + badMutation := filepath.Join(dir, "bad-mutation.json") + if err := os.WriteFile(badMutation, []byte("{not-json}"), 0644); err != nil { + t.Fatalf("failed to write bad mutation facts: %v", err) + } + if _, err := processMutationFacts(badMutation, dir); err == nil { + t.Fatalf("malformed mutation facts should fail") + } + emptyMutation := filepath.Join(dir, "empty-mutation.json") + if err := os.WriteFile(emptyMutation, []byte(`{"subjects":"not an array"}`), 0644); err != nil { + t.Fatalf("failed to write empty mutation facts: %v", err) + } + out, err := processMutationFacts(emptyMutation, dir) + if err != nil { + t.Fatalf("empty mutation facts failed: %v", err) + } + if !out.Active || len(out.Subjects) != 0 { + t.Fatalf("empty mutation facts mismatch: %+v", out) + } +} + +func TestLoadLineageHandlesAbsentInvalidAndCommandBackedIndexes(t *testing.T) { + dir := t.TempDir() + if got := loadLineage("", dir, nil, 10, ""); got.Status != "absent" { + t.Fatalf("empty lineage db status = %+v", got) + } + if got := loadLineage(filepath.Join(dir, "missing.sqlite3"), dir, nil, 10, ""); got.Status != "absent" { + t.Fatalf("missing lineage db status = %+v", got) + } + db := filepath.Join(dir, "lineage.sqlite3") + if err := os.WriteFile(db, []byte("not actually sqlite"), 0644); err != nil { + t.Fatalf("failed to write db marker: %v", err) + } + badJSON := filepath.Join(dir, "bad-lineage") + writeTempFile(t, dir, "bad-lineage", "#!/bin/sh\necho not-json\n") + if err := os.Chmod(badJSON, 0755); err != nil { + t.Fatalf("chmod bad lineage: %v", err) + } + if got := loadLineage(db, dir, []string{"src"}, 5, badJSON); got.Status != "absent" { + t.Fatalf("invalid lineage JSON status = %+v", got) + } + writeTempFile(t, dir, "src/a.rb", "def call\nend\n") + good := filepath.Join(dir, "good-lineage") + writeTempFile(t, dir, "good-lineage", `#!/bin/sh +echo '[{"current_path":"src/a.rb","name":"call","risk_score":0,"fixes":1,"changes":2,"moves":0,"current_distinct_tests":1}]' +`) + if err := os.Chmod(good, 0755); err != nil { + t.Fatalf("chmod good lineage: %v", err) + } + index := loadLineage(db, dir, []string{"src"}, 5, good) + if index.Status != "ok" || !index.HasTestExposure || index.Label != "lineage.sqlite3" { + t.Fatalf("lineage index metadata mismatch: %+v", index) + } + if len(index.Units) != 1 || index.Index[MethodKey{File: "src/a.rb", Method: "call"}].Name != "call" { + t.Fatalf("lineage units/index mismatch: %+v", index) + } +} + +func TestReportRenderingCoversAllRiskSectionsAndLimits(t *testing.T) { + ranked := []HotspotRow{ + {File: "src/high.rb", FixNorm: 1, Gap: 0.9, Hotspot: 0.9, TotalBranches: 10, Uncovered: 9}, + {File: "src/low.rb", FixNorm: 0.5, Gap: 0.8, Hotspot: 0.4, TotalBranches: 5, Uncovered: 4}, + } + unmeasured := []UnmeasuredEntry{ + {File: "src/missing.rb", FixNorm: 0.75}, + {File: "src/other_missing.rb", FixNorm: 0.25}, + } + methods := []MethodGapRow{ + { + File: "src/high.rb", + Name: "danger", + FirstLine: 10, + LastLine: 20, + ExecutableLines: 10, + CoveredLines: 0, + MissedLines: 10, + LineGap: 1.0, + Risk: 12.5, + FixNorm: 1.0, + DecomplexScore: 2, + StateWrites: 1, + UncoveredBranches: 2, + TestExposureStatus: "", + TestExposureProfile: "named coverage", + VerificationStatus: "", + DecomplexFindings: []Finding{{Type: "state_branch_density"}}, + LineageScore: 4.2, + LineageFixes: 1, + LineageChanges: 2, + LineageMoves: 0, + }, + { + File: "src/aaa.rb", + Name: "tie", + FirstLine: 5, + LastLine: 8, + ExecutableLines: 5, + CoveredLines: 1, + MissedLines: 4, + LineGap: 0.8, + Risk: 12.5, + FixNorm: 0.5, + DecomplexScore: 1, + StateWrites: 0, + UncoveredBranches: 0, + }, + } + stateRows := []StateBranchHotspotRow{ + {File: "src/high.rb", Method: "danger", Risk: 7.5, Decisions: 4, At: "src/high.rb:11", StateRefs: []string{"@a", "@b", "@c", "@d", "@e", "@f"}, FixNorm: 1, BranchGap: 0.9, LineGap: 1.0, DarkBranches: 2}, + {File: "src/low.rb", Method: "calm", Risk: 1.0, Decisions: 1, At: 27}, + } + blast := []BlastRow{ + {File: "src/high.rb", Score: 1.25, Fixes: 3, AvgTouched: 2.5, MaxTouched: 4, Partners: [][2]interface{}{{"src/peer.rb", 2}}}, + {File: "src/low.rb", Score: 0.5, Fixes: 1, AvgTouched: 1, MaxTouched: 1}, + } + lineage := LineageIndex{ + Status: "ok", + Label: "lineage.sqlite3", + HasTestExposure: true, + Units: []LineageUnit{ + {File: "src/high.rb", Name: "danger", RiskScore: 4.2, Fixes: 2, Changes: 3, Moves: 1, TotalEvents: 6}, + {File: "src/low.rb", Name: "calm", RiskScore: 1.2, Fixes: 1, Changes: 1, Moves: 0, TotalEvents: 2}, + }, + } + + md := generateMarkdown( + "/repo", + []string{"src"}, + nil, + 5, + ranked, + unmeasured, + methods, + stateRows, + blast, + lineage, + false, + "", + true, + "mutation.json", + true, + "exposure.json", + 1, + ) + for _, want := range []string{ + "NoCoverageWarning", + "WARNING: no branch-coverage resultset supplied", + "Highest state-based branch hotspot", + "Highest multi-file fix blast radius", + "Highest empirical method risk", + "Highest lineage unit risk", + "not supplied | not supplied | named coverage", + "...(+1 more)", + "ABSENT (fix-churn only)", + "mutation.json", + "exposure.json", + "lineage.sqlite3", + } { + if want == "NoCoverageWarning" { + continue + } + if !strings.Contains(md, want) { + t.Fatalf("markdown missing %q:\n%s", want, md) + } + } + if strings.Contains(md, "@f") { + t.Fatalf("state refs should be limited to five entries:\n%s", md) + } + + sarifJSON := generateSarif( + "/repo", + nil, + []string{"src/high.rb"}, + 5, + ranked, + unmeasured, + methods, + stateRows, + blast, + lineage, + true, + "coverage.xml", + true, + "mutation.json", + true, + "exposure.json", + 2, + ) + var doc SarifDoc + if err := json.Unmarshal([]byte(sarifJSON), &doc); err != nil { + t.Fatalf("failed to parse sarif: %v\n%s", err, sarifJSON) + } + if len(doc.Runs) != 1 { + t.Fatalf("sarif runs = %d; want 1", len(doc.Runs)) + } + results := doc.Runs[0].Results + if len(results) != 12 { + t.Fatalf("sarif results = %d; want all six sections with two rows each: %+v", len(results), results) + } + seen := make(map[string]int) + for _, result := range results { + seen[result.RuleID]++ + } + for _, ruleID := range []string{ + "boobytrap.file-hotspot", + "boobytrap.dark-method", + "boobytrap.state-branch-hotspot", + "boobytrap.fix-blast-radius", + "boobytrap.lineage-unit-risk", + "boobytrap.fixed-unmeasured", + } { + if seen[ruleID] != 2 { + t.Fatalf("sarif rule %s count = %d; want 2", ruleID, seen[ruleID]) + } + } +} + +func TestMethodGapComputationCombinesCoverageBranchesAndEvidence(t *testing.T) { + dir := t.TempDir() + source := strings.Join([]string{ + "func risky() {", + " config.Value = 1", + " if flag {", + " return 1", + " }", + " return 0", + "}", + "func covered() {", + " a := 1", + " b := 2", + " c := 3", + " return a + b + c", + "}", + "", + }, "\n") + writeTempFile(t, dir, "subject.go", source) + file := filepath.Join(dir, "subject.go") + dataset := &CoverageDataset{Files: map[string]FileCoverage{ + file: { + Lines: []*int{ + ptr(1), ptr(0), ptr(0), ptr(1), nil, ptr(0), nil, + ptr(1), ptr(1), ptr(1), ptr(1), ptr(1), nil, + }, + Branches: map[string]map[string]int{ + "[:if,0,3,1,3,9]": { + "[:then,1,3,1,3,4]": 0, + "[:else,2,3,5,3,9]": 1, + }, + }, + SourcePath: "subject.go", + Format: "simplecov", + Language: "go", + }, + filepath.Join(dir, "missing.go"): { + Lines: []*int{ptr(0)}, + Branches: map[string]map[string]int{}, + }, + }} + evidence := map[MethodKey]DecomplexScoreEntry{ + {File: "subject.go", Method: "risky"}: { + File: "subject.go", + Method: "risky", + Score: 2, + Findings: []Finding{{Type: "state_branch_density"}}, + Detectors: []string{"state_branch_density", "unused_return"}, + }, + } + + rows := computeMethodGapsFromCoverage(dataset, dir, evidence) + if len(rows) != 1 { + t.Fatalf("method gap rows = %+v; want only risky row", rows) + } + row := rows[0] + if row.File != "subject.go" || row.Name != "risky" { + t.Fatalf("unexpected method row identity: %+v", row) + } + if row.ExecutableLines != 5 || row.CoveredLines != 2 || row.MissedLines != 3 { + t.Fatalf("line accounting mismatch: %+v", row) + } + if row.LineGap != 0.6 || row.StateWrites != 1 || row.UncoveredBranches != 1 { + t.Fatalf("risk inputs mismatch: %+v", row) + } + if row.DecomplexScore != 2 || len(row.DecomplexFindings) != 1 || len(row.DecomplexDetectors) != 2 { + t.Fatalf("decomplex evidence mismatch: %+v", row) + } + if math.Abs(row.Risk-6.3) > 0.0001 { + t.Fatalf("risk = %.4f; want 6.3", row.Risk) + } +} + +func TestStaticMethodGapComputationUsesSyntaxBranchFacts(t *testing.T) { + dir := t.TempDir() + source := strings.Join([]string{ + "def risky", + " @state = 1", + " if cond", + " true", + " else", + " false", + " end", + "end", + "", + }, "\n") + writeTempFile(t, dir, "subject.rb", source) + armOne := json.RawMessage(`{"line":3}`) + armTwo := json.RawMessage(`{"line":6}`) + evidence := map[MethodKey]DecomplexScoreEntry{ + {File: "subject.rb", Method: "risky"}: { + Score: 1, + Findings: []Finding{{Type: "unused_return"}}, + Detectors: []string{"unused_return"}, + }, + } + + rows := computeMethodGapsFromStatic( + []string{"subject.rb", filepath.Join(dir, "missing.rb")}, + dir, + evidence, + []FactMineDoc{{ + File: filepath.Join(dir, "subject.rb"), + BranchArms: []json.RawMessage{armOne, armTwo, json.RawMessage(`{"not_line":99}`)}, + }}, + ) + if len(rows) != 1 { + t.Fatalf("static method gap rows = %+v; want risky row", rows) + } + row := rows[0] + if row.File != "subject.rb" || row.Name != "risky" { + t.Fatalf("unexpected static row identity: %+v", row) + } + if row.ExecutableLines != 6 || row.MissedLines != 6 || row.CoveredLines != 0 || row.LineGap != 1.0 { + t.Fatalf("static line accounting mismatch: %+v", row) + } + if row.StateWrites != 1 || row.UncoveredBranches != 2 || row.DecomplexScore != 1 { + t.Fatalf("static risk inputs mismatch: %+v", row) + } + if math.Abs(row.Risk-9.5) > 0.0001 { + t.Fatalf("static risk = %.4f; want 9.5", row.Risk) + } +} + +func TestStateBranchHotspotsUseMethodAndCoverageRiskInputs(t *testing.T) { + rows := buildStateBranchHotspots( + []StateBranchDensityRow{ + {File: "b.rb", Method: "tie", Score: 2, Decisions: 5, At: "b.rb:10", StateRefs: []string{"@x"}, Predicate: "x?"}, + {File: "a.rb", Method: "winner", Score: 3, Decisions: 2, At: "a.rb:3"}, + {File: "c.rb", Method: "fallback", Score: 1, Decisions: 10}, + }, + []MethodGapRow{ + {File: "a.rb", Name: "winner", LineGap: 0.5, UncoveredBranches: 2, TestExposureMultiplier: 0.5}, + {File: "b.rb", Name: "tie", LineGap: 0.1, UncoveredBranches: 0, TestExposureMultiplier: 1.0}, + }, + map[string]float64{"a.rb": 2, "b.rb": 1}, + 2, + map[string]FileGap{"a.rb": {Gap: 0.25}}, + ) + if len(rows) != 3 { + t.Fatalf("state branch hotspot rows = %+v", rows) + } + if rows[0].File != "a.rb" || rows[0].Method != "winner" { + t.Fatalf("winner row did not rank first: %+v", rows) + } + if rows[0].FixNorm != 1.0 || rows[0].BranchGap != 0.25 || rows[0].LineGap != 0.5 || rows[0].DarkBranches != 2 { + t.Fatalf("winner row inputs mismatch: %+v", rows[0]) + } + if rows[2].File != "c.rb" || rows[2].FixNorm != 0 || rows[2].LineGap != 0 { + t.Fatalf("fallback row inputs mismatch: %+v", rows[2]) + } +} + +func TestReportSortTieBreakersAreStable(t *testing.T) { + stateRows := buildStateBranchHotspots( + []StateBranchDensityRow{ + {File: "b.rb", Method: "m", Score: 1, Decisions: 1}, + {File: "a.rb", Method: "z", Score: 1, Decisions: 1}, + {File: "a.rb", Method: "a", Score: 1, Decisions: 1}, + {File: "c.rb", Method: "d", Score: 1, Decisions: 2}, + }, + nil, + nil, + 0, + nil, + ) + gotStateOrder := []string{ + stateRows[0].File + ":" + stateRows[0].Method, + stateRows[1].File + ":" + stateRows[1].Method, + stateRows[2].File + ":" + stateRows[2].Method, + stateRows[3].File + ":" + stateRows[3].Method, + } + if strings.Join(gotStateOrder, ",") != "c.rb:d,a.rb:a,a.rb:z,b.rb:m" { + t.Fatalf("state tie order = %+v", gotStateOrder) + } + + methods := []MethodGapRow{ + {File: "z.rb", Name: "highest", FirstLine: 1, LastLine: 2, LineGap: 1, Risk: 6, MissedLines: 1, ExecutableLines: 2}, + {File: "b.rb", Name: "missed", FirstLine: 1, LastLine: 2, LineGap: 1, Risk: 5, MissedLines: 3, ExecutableLines: 4}, + {File: "a.rb", Name: "file", FirstLine: 2, LastLine: 3, LineGap: 1, Risk: 5, MissedLines: 2, ExecutableLines: 3}, + {File: "a.rb", Name: "line", FirstLine: 1, LastLine: 2, LineGap: 1, Risk: 5, MissedLines: 2, ExecutableLines: 3}, + } + md := generateMarkdown( + "/repo", + nil, + nil, + 0, + nil, + nil, + methods, + nil, + nil, + LineageIndex{Status: "absent"}, + true, + "coverage.xml", + false, + "", + false, + "", + 10, + ) + first := strings.Index(md, "`z.rb:1` `highest`") + second := strings.Index(md, "`b.rb:1` `missed`") + third := strings.Index(md, "`a.rb:1` `line`") + fourth := strings.Index(md, "`a.rb:2` `file`") + if !(first >= 0 && second > first && third > second && fourth > third) { + t.Fatalf("dark method markdown sort order unexpected:\n%s", md) + } + + sarifJSON := generateSarif( + "/repo", + nil, + nil, + 0, + nil, + nil, + methods, + nil, + nil, + LineageIndex{Status: "absent"}, + true, + "coverage.xml", + false, + "", + false, + "", + 10, + ) + var sarif SarifDoc + if err := json.Unmarshal([]byte(sarifJSON), &sarif); err != nil { + t.Fatalf("failed to parse sarif: %v", err) + } + var dark []SarifResult + for _, result := range sarif.Runs[0].Results { + if result.RuleID == "boobytrap.dark-method" { + dark = append(dark, result) + } + } + if len(dark) != 4 || dark[0].Locations[0].PhysicalLocation.ArtifactLocation.URI != "z.rb" || + dark[1].Locations[0].PhysicalLocation.ArtifactLocation.URI != "b.rb" || + dark[2].Locations[0].PhysicalLocation.Region.StartLine != 1 || + dark[3].Locations[0].PhysicalLocation.Region.StartLine != 2 { + t.Fatalf("dark method SARIF sort order mismatch: %+v", dark) + } +} + func TestProcessMutationFacts(t *testing.T) { tmp, err := os.MkdirTemp("", "boobytrap-mutation-test") if err != nil { @@ -738,7 +2227,7 @@ func TestReportSubcommandMinimal(t *testing.T) { writeTempFile(t, dir, "b.py", srcFileContent) writeTempFile(t, dir, "c.rb", "def foo\n x = 1\nend\n") writeTempFile(t, dir, "d.go", "package main\n") - runCmd(t, dir, "git", "add", "b.py" ,"c.rb", "d.go") + runCmd(t, dir, "git", "add", "b.py", "c.rb", "d.go") runCmd(t, dir, "git", "commit", "-qm", "Fix issue") // 2. Create static-files JSON containing our files diff --git a/gems/nil-kill/lib/nil_kill/commands/collect_runtime_command.rb b/gems/nil-kill/lib/nil_kill/commands/collect_runtime_command.rb index eb2c84606..91676248c 100644 --- a/gems/nil-kill/lib/nil_kill/commands/collect_runtime_command.rb +++ b/gems/nil-kill/lib/nil_kill/commands/collect_runtime_command.rb @@ -14,7 +14,7 @@ def run output = File.expand_path(option("--output") || RUNTIME_DIR, ROOT) targets = options("--target") targets = ["src"] if targets.empty? - append = @argv.delete("--append-runtime") + append = !!@argv.delete("--append-runtime") provider = Languages.provider_for(language) provider.collect_runtime(argv: @argv, root: root, output: output, targets: targets, append: append) diff --git a/gems/nil-kill/lib/nil_kill/detector_runner.rb b/gems/nil-kill/lib/nil_kill/detector_runner.rb deleted file mode 100644 index 6621901b3..000000000 --- a/gems/nil-kill/lib/nil_kill/detector_runner.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -require "json" -require_relative "co_update" -require_relative "flay_similarity" -require_relative "native/co_update" -require_relative "native/predicate_aliases" -require_relative "native/flay_similarity" -require_relative "predicate_alias" - -module Decomplex - # Runs one detector in isolation and emits deterministic machine output. - # - # This is intentionally narrower than Report: it gives parser/runtime - # migration work an apples-to-apples target that excludes report wording, - # timing, SARIF metadata, and other nondeterministic details. - module DetectorRunner - DETECTORS = { - "co-update" => :co_update, - "predicate-alias" => :predicate_alias, - "predicate-aliases" => :predicate_alias, - "flay-similarity" => :flay_similarity, - "structural-similarity" => :flay_similarity - }.freeze - ENGINES = %w[ruby rust].freeze - - module_function - - def run(detector, files, engine: "ruby", mass: FlaySimilarity::DEFAULT_MASS, fuzzy: FlaySimilarity::DEFAULT_FUZZY, jobs: nil) - canonical = canonical_detector(detector) - validate_engine!(engine) - - case canonical - when :co_update - co_update(files, engine: engine, jobs: jobs) - when :predicate_alias - predicate_alias(files, engine: engine, jobs: jobs) - when :flay_similarity - flay_similarity(files, engine: engine, mass: mass, fuzzy: fuzzy, jobs: jobs) - else - raise ArgumentError, "unsupported decomplex detector: #{detector}" - end - end - - def canonical_json(detector, files, engine: "ruby", **options) - JSON.generate(canonicalize(run(detector, files, engine: engine, **options))) << "\n" - end - - def compare(detector, files, **options) - ruby_json = canonical_json(detector, files, engine: "ruby", **options) - rust_json = canonical_json(detector, files, engine: "rust", **options) - [ruby_json == rust_json, ruby_json, rust_json] - end - - def detector_names - DETECTORS.keys - end - - private_class_method def self.canonical_detector(detector) - DETECTORS.fetch(detector.to_s) do - raise ArgumentError, "unsupported decomplex detector: #{detector}" - end - end - - private_class_method def self.validate_engine!(engine) - return if ENGINES.include?(engine.to_s) - - raise ArgumentError, "unsupported decomplex detector engine: #{engine}" - end - - private_class_method def self.co_update(files, engine:, jobs:) - return Native::CoUpdate.scan(files, jobs: jobs) if engine.to_s == "rust" - - report = CoUpdate.scan(files) - - { - "co_written_pairs" => report.co_written_pairs, - "neglected_updates" => report.neglected_updates - } - end - - private_class_method def self.predicate_alias(files, engine:, jobs:) - return Native::PredicateAliases.scan(files, jobs: jobs) if engine.to_s == "rust" - - report = PredicateAlias.scan(files) - - { "alias_clusters" => report.alias_clusters } - end - - private_class_method def self.flay_similarity(files, engine:, mass:, fuzzy:, jobs:) - findings = - if engine.to_s == "rust" - Native::FlaySimilarity.scan(files, mass: mass, fuzzy: fuzzy, jobs: jobs) - else - FlaySimilarity.scan(files, mass: mass, fuzzy: fuzzy) - end - - { "findings" => findings } - end - - private_class_method def self.canonicalize(value) - case value - when Hash - value.keys.map(&:to_s).sort.each_with_object({}) do |key, out| - original = value.key?(key) ? key : value.keys.find { |candidate| candidate.to_s == key } - out[key] = canonicalize(value.fetch(original)) - end - when Array - value.map { |item| canonicalize(item) } - when Symbol - value.to_s - else - value - end - end - end -end diff --git a/gems/nil-kill/lib/nil_kill/native/state_writes.rb b/gems/nil-kill/lib/nil_kill/native/state_writes.rb deleted file mode 100644 index a36b1fb83..000000000 --- a/gems/nil-kill/lib/nil_kill/native/state_writes.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require "json" -require_relative "../co_update" -require_relative "command" - -module Decomplex - module Native - # Bridge from the Ruby detector layer to the native Decomplex state-write - # fact extractor. The full co-update detector now runs in native Rust too; - # this module remains for focused fact debugging. - module StateWrites - module_function - - def extract(files) - paths = Array(files).map(&:to_s) - validate_ruby_files!(paths) - payload = run_native(paths) - JSON.parse(payload).map do |row| - CoUpdate::Write.new( - attr: row.fetch("field"), - recv: row.fetch("receiver"), - file: row.fetch("file"), - defn: row.fetch("function"), - line: row.fetch("line"), - span: row.fetch("span"), - ) - end - end - - private_class_method def self.validate_ruby_files!(paths) - bad = paths.reject { |path| File.extname(path) == ".rb" } - return if bad.empty? - - raise ArgumentError, "--engine=rust currently supports Ruby files only: #{bad.join(', ')}" - end - - private_class_method def self.run_native(paths) - Command.run("state-writes", "--language", "ruby", *paths) - end - end - end -end diff --git a/gems/nil-kill/lib/nil_kill/runtime_trace.rb b/gems/nil-kill/lib/nil_kill/runtime_trace.rb index 4831ea339..67ba9e3ec 100755 --- a/gems/nil-kill/lib/nil_kill/runtime_trace.rb +++ b/gems/nil-kill/lib/nil_kill/runtime_trace.rb @@ -130,6 +130,7 @@ def self.trace_plan current_targets = TARGETS.map { |path| File.expand_path(path, ROOT) }.sort @trace_plan = nil unless plan_targets == current_targets end + @trace_plan rescue JSON::ParserError @trace_plan = nil end @@ -1485,10 +1486,14 @@ def self.install_array_hook result end - define_method(:concat) do |other| - result = super(other) + define_method(:concat) do |*others| + result = super(*others) if @__nil_kill_traced - NilKillRuntimeTrace.with_collection_hook_guard { Array(other).first(NilKillRuntimeTrace::ELEMENT_SAMPLE).each { |value| NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } } + NilKillRuntimeTrace.with_collection_hook_guard do + others.each do |other| + Array(other).first(NilKillRuntimeTrace::ELEMENT_SAMPLE).each { |value| NilKillRuntimeTrace.record_collection_mutation(self, elem: value) } + end + end end result end @@ -1852,7 +1857,10 @@ def self.dump # instead of comparing against a stale aggregate SimpleCov. def self.start_coverage! require "coverage" - return if Coverage.respond_to?(:running?) && Coverage.running? + if Coverage.respond_to?(:running?) && Coverage.running? + @coverage_owned = ENV["NIL_KILL_SHARED_COVERAGE"] == "1" + return + end Coverage.start(lines: true) @coverage_owned = true rescue StandardError, LoadError @@ -1884,7 +1892,7 @@ def self.coverage_line_map def self.src_line(path, lineno) rel = Pathname.new(abs_path(path)).relative_path_from(Pathname.new(ROOT)).to_s rescue nil per_file = rel && coverage_line_map[rel] - (per_file && per_file[lineno]) || lineno + (per_file && (per_file[lineno] || per_file[lineno.to_s])) || lineno end def self.dump_coverage(pid) @@ -1906,7 +1914,7 @@ def self.dump_coverage(pid) Array(lines).each_with_index do |hits, i| next unless hits && hits.to_i.positive? instr_line = i + 1 - src_line = per_file ? per_file[instr_line] : instr_line + src_line = per_file ? (per_file[instr_line] || per_file[instr_line.to_s]) : instr_line covered << src_line if src_line end covered = covered.uniq.sort diff --git a/gems/nil-kill/spec/coverage_hardening_spec.rb b/gems/nil-kill/spec/coverage_hardening_spec.rb new file mode 100644 index 000000000..3349fc47d --- /dev/null +++ b/gems/nil-kill/spec/coverage_hardening_spec.rb @@ -0,0 +1,1874 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require_relative "../lib/nil_kill/native/command" +require_relative "../lib/nil_kill/native/co_update" +require_relative "../lib/nil_kill/native/flay_similarity" +require_relative "../lib/nil_kill/native/predicate_aliases" +require_relative "../lib/nil_kill/runtime_trace" + +RSpec.describe "NilKill coverage hardening" do + def capture_io + old_stdout = $stdout + old_stderr = $stderr + $stdout = StringIO.new + $stderr = StringIO.new + yield + [$stdout.string, $stderr.string] + ensure + $stdout = old_stdout + $stderr = old_stderr + end + + def status(success) + instance_double(Process::Status, success?: success) + end + + describe NilKill::CLI do + before do + allow(NilKill).to receive(:ensure_src_restored!) + end + + it "dispatches supported subcommands to their command objects" do + cases = [ + ["static", NilKill::Commands::StaticCommand], + ["collect-runtime", NilKill::Commands::CollectRuntimeCommand], + ["collect-python", NilKill::Commands::CollectPythonCommand], + ["normalize", NilKill::Commands::NormalizeCommand], + ["analyze", NilKill::Commands::AnalyzeCommand], + ["trace-spec", NilKill::Commands::TraceSpecCommand], + ["focus-hash-record", NilKill::FocusHashRecord], + ["struct-rbi", NilKill::StructRBI], + ] + + cases.each do |command, klass| + runner = instance_double(klass, run: true) + expect(klass).to receive(:new).with(["--flag"]).and_return(runner) + described_class.new([command, "--flag"]).run + end + end + + it "runs infer only after the runtime freshness guard" do + runner = instance_double(NilKill::Infer, run: true) + cli = described_class.new(["infer", "--allow-stale-runtime"]) + + expect(cli).to receive(:guard_fresh_runtime!).and_call_original + expect(NilKill::Infer).to receive(:new).with([]).and_return(runner) + + cli.run + end + + it "runs report with explicit evidence without invoking stale evidence guard" do + runner = instance_double(NilKill::Report, run: true) + cli = described_class.new(["report", "--evidence", "tmp/evidence.json"]) + + expect(cli).not_to receive(:guard_fresh_evidence!) + expect(NilKill::Report).to receive(:new).with(["--evidence", "tmp/evidence.json"]).and_return(runner) + + cli.run + end + + it "prints help for help and exits on unknown commands" do + out, = capture_io { described_class.new(["help"]).run } + expect(out).to include("bundle exec tools/nil-kill collect") + + expect do + capture_io { described_class.new(["unknown"]).run } + end.to raise_error(SystemExit) { |error| expect(error.status).to eq(2) } + end + + it "parses command files, command strings, globs, templates, and trailing commands" do + Dir.mktmpdir do |dir| + command_file = File.join(dir, "commands.txt") + File.write(command_file, "# ignored\nruby -e 'puts 1'\n\nbundle exec rspec\n") + a = File.join(dir, "a.rb") + b = File.join(dir, "b.rb") + File.write(a, "") + File.write(b, "") + + cli = described_class.new([ + "collect", + "--commands", command_file, + "--cmd", "ruby -w", + "--glob", File.join(dir, "*.rb"), + "--template", "ruby {file}", + "--", "bundle", "exec", "ruby", "script.rb", + ]) + + commands = cli.send(:collect_commands) + expect(commands).to include(["ruby", "-e", "puts 1"]) + expect(commands).to include(["bundle", "exec", "rspec"]) + expect(commands).to include(["ruby", "-w"]) + expect(commands).to include(["ruby", a], ["ruby", b]) + expect(commands).to include(["bundle", "exec", "ruby", "script.rb"]) + end + end + + it "writes and compares collect metadata by content, not mtime alone" do + Dir.mktmpdir do |dir| + cli = described_class.new([]) + meta = File.join(dir, "collect-meta.json") + allow(cli).to receive(:collect_meta_path).and_return(meta) + + allow(cli).to receive(:git_capture) do |*args| + case args + when ["rev-parse", "HEAD"] then "abc123\n" + when ["status", "--porcelain", "--", *NilKill.target_dirs] then "" + else nil + end + end + + cli.send(:write_collect_meta!) + expect(JSON.parse(File.read(meta))).to include("head" => "abc123", "dirty" => "") + expect(cli.send(:targets_changed_since_collect)).to be(false) + + allow(cli).to receive(:git_capture) do |*args| + case args + when ["rev-parse", "HEAD"] then "def456" + when ["status", "--porcelain", "--", *NilKill.target_dirs] then " M src/app.rb\n" + when ["diff", "--name-only", "abc123..def456", "--", *NilKill.target_dirs] then "src/app.rb\n" + else nil + end + end + + expect(cli.send(:targets_changed_since_collect)).to eq(["abc123", "def456", ["src/app.rb"]]) + end + end + + it "uses conservative runtime/evidence guards and supports the stale override" do + cli = described_class.new(["--allow-stale-runtime"]) + expect { cli.send(:guard_fresh_runtime!) }.not_to raise_error + + cli = described_class.new([]) + allow(Dir).to receive(:glob).with(File.join(NilKill::RUNTIME_DIR, "*.jsonl")).and_return([]) + expect { capture_io { cli.send(:guard_fresh_runtime!) } }.to raise_error(SystemExit) + + Dir.mktmpdir do |dir| + evidence = File.join(dir, "evidence.json") + File.write(evidence, "{}") + stub_const("NilKill::EVIDENCE_PATH", evidence) + cli = described_class.new([]) + allow(cli).to receive(:targets_changed_since_collect).and_return(["oldhead", "newhead", Array.new(10) { |i| "src/f#{i}.rb" }]) + + expect { capture_io { cli.send(:guard_fresh_evidence!) } }.to raise_error(SystemExit) + end + end + + it "falls back to mtimes when git metadata is unknown" do + Dir.mktmpdir do |dir| + src = File.join(dir, "src.rb") + File.write(src, "1") + allow(NilKill).to receive(:target_files).and_return([src]) + + old_runtime = Time.now - 60 + new_runtime = Time.now + 60 + expect(described_class.new([]).send(:mtime_stale?, old_runtime)).to be(true) + expect(described_class.new([]).send(:mtime_stale?, new_runtime)).to be(false) + end + end + + it "runs collect with instrumentation, trace-plan setup, worker env, and cleanup" do + cli = described_class.new(["collect", "--continue-on-error", "--", "ruby", "-e", "0"]) + allow(cli).to receive(:collect_commands).and_return([["ruby", "-e", "0"], ["ruby", "-e", "1"]]) + allow(cli).to receive(:write_collect_meta!) + allow(cli).to receive(:acquire_inplace_lock!) + allow(cli).to receive(:install_inplace_restore_traps!) + allow(cli).to receive(:assert_collect_coverage_produced!) + allow(NilKill::TracePlan).to receive(:write) + allow(NilKill).to receive(:target_files).and_return(["src/app.rb"]) + allow(NilKill).to receive(:write_inplace_sentinel!) + allow(NilKill).to receive(:restore_inplace_snapshot!) + instrumenter = instance_double(NilKill::SourceInstrumenter, run_in_place: true) + expect(NilKill::SourceInstrumenter).to receive(:new).and_return(instrumenter) + seen_env = [] + allow(cli).to receive(:system) do |env, *cmd| + seen_env << env + expect(cmd).to eq(["ruby", "-e", seen_env.size == 1 ? "0" : "1"]) + seen_env.size == 1 + end + + capture_io { cli.run } + + expect(NilKill::TracePlan).to have_received(:write) + expect(NilKill).to have_received(:restore_inplace_snapshot!) + expect(seen_env.first).to include("NIL_KILL_TRACE" => "1") + expect(seen_env.first["RUBYOPT"]).to include("runtime_trace.rb") + expect(seen_env.first["NIL_KILL_TRACE_METHODS"]).to eq("0") + end + + it "surfaces focused hash-record actions and doctor dependency probes" do + Dir.mktmpdir("nil-kill-focus", NilKill::ROOT) do |dir| + src = File.join(dir, "src", "record.rb") + FileUtils.mkdir_p(File.dirname(src)) + File.write(src, "record = {name: 'Ada'}\n") + action = { + "kind" => "promote_hash_record_cluster_to_struct", + "data" => { + "struct_name" => "UserRecord", + "pressure" => { "total" => 3 }, + "producers" => [{ "path" => src }], + "consumers" => [{ "path" => src }], + "signatures" => [{ "path" => src }], + "blockers" => [], + }, + } + + allow(NilKill::Store).to receive(:read).and_return("actions" => [action]) + store = instance_double(NilKill::Store, actions: [action]) + allow(NilKill::Store).to receive(:new).and_return(store) + allow(NilKill::StaticAnalysis).to receive(:index_store) + allow_any_instance_of(NilKill::Infer).to receive(:propose_hash_record_cluster_actions) + + out, = capture_io { NilKill::FocusHashRecord.new(["UserRecord"]).run } + expect(out).to include("focused hash-record UserRecord", "pressure: 3") + + doctor = NilKill::Doctor.new + allow(NilKill).to receive(:target_dirs).and_return([File.join(NilKill::ROOT, "src")]) + allow(NilKill).to receive(:target_exclude_dirs).and_return([File.join(NilKill::ROOT, "tmp")]) + allow(Open3).to receive(:capture3).and_return(["", "", status(false)]) + allow(doctor).to receive(:gem_ok?).and_return(nil) + out, = capture_io { doctor.run } + expect(out).to include("ruby:", "sorbet: missing/error", "excluded targets:") + end + end + + it "covers doctor dispatch, stale guards, collect failure exits, and evidence assertions" do + doctor = instance_double(NilKill::Doctor, run: true) + expect(NilKill::Doctor).to receive(:new).and_return(doctor) + described_class.new(["doctor"]).run + + cli = described_class.new([]) + expect(cli.send(:explicit_evidence_path?, ["--evidence=tmp/evidence.json"])).to be(true) + expect(cli.send(:explicit_evidence_path?, ["--flag"])).to be(false) + + allow(Open3).to receive(:capture2e).and_raise(Errno::ENOENT) + expect(cli.send(:git_capture, "status")).to be_nil + + allow(cli).to receive(:collect_meta_path).and_return("/missing/meta.json") + expect(cli.send(:targets_changed_since_collect)).to eq(:unknown) + + runtime_file = File.join(Dir.tmpdir, "nil-kill-runtime-guard.jsonl") + File.write(runtime_file, "{}") + stale_cli = described_class.new([]) + allow(Dir).to receive(:glob).and_call_original + allow(Dir).to receive(:glob).with(File.join(NilKill::RUNTIME_DIR, "*.jsonl")).and_return([runtime_file]) + allow(stale_cli).to receive(:targets_changed_since_collect).and_return(["oldsha12345", "newsha67890", Array.new(10) { |i| "src/f#{i}.rb" }]) + expect { capture_io { stale_cli.send(:guard_fresh_runtime!) } }.to raise_error(SystemExit) + + evidence_cli = described_class.new([]) + stub_const("NilKill::TMP_DIR", Dir.mktmpdir("nil-kill-missing-evidence", NilKill::ROOT)) + expect { capture_io { evidence_cli.send(:guard_fresh_evidence!) } }.to raise_error(SystemExit) + + failing_collect = described_class.new(["collect", "--no-instrument-source", "--", "ruby", "-e", "exit 1"]) + allow(failing_collect).to receive(:collect_commands).and_return([["ruby", "-e", "exit 1"]]) + allow(failing_collect).to receive(:write_collect_meta!) + allow(failing_collect).to receive(:system).and_return(false) + allow(failing_collect).to receive(:assert_collect_coverage_produced!) + allow(NilKill::TracePlan).to receive(:write) + expect { capture_io { failing_collect.send(:collect) } }.to raise_error(SystemExit) + + lock_cli = described_class.new([]) + fake_file = instance_double(File, flock: false) + allow(File).to receive(:open).and_return(fake_file) + expect { capture_io { lock_cli.send(:acquire_inplace_lock!) } }.to raise_error(SystemExit) + + assert_cli = described_class.new([]) + allow(Dir).to receive(:glob).with(File.join(NilKill::RUNTIME_DIR, "coverage-*.jsonl")).and_return([runtime_file]) + allow(Dir).to receive(:glob).with(File.join(NilKill::RUNTIME_DIR, "methods-*.jsonl")).and_return([]) + expect { capture_io { assert_cli.send(:assert_collect_coverage_produced!) } }.to raise_error(SystemExit) + ensure + FileUtils.rm_f(runtime_file) if defined?(runtime_file) + end + end + + describe NilKill::Commands::CollectRuntimeCommand do + it "parses options and delegates to the requested language provider" do + provider = instance_double(NilKill::Languages::Provider) + expect(NilKill::Languages).to receive(:provider_for).with("python").and_return(provider) + expect(provider).to receive(:collect_runtime).with( + argv: ["--", "pytest"], + root: File.expand_path("."), + output: File.expand_path("tmp/runtime", NilKill::ROOT), + targets: ["app", "lib"], + append: true + ) + + described_class.new([ + "--language=python", + "--output", "tmp/runtime", + "--target", "app", + "--target=lib", + "--append-runtime", + "--", "pytest", + ]).run + end + + it "turns unsupported runtime tracers into a user-facing abort" do + provider = instance_double(NilKill::Languages::Provider) + allow(NilKill::Languages).to receive(:provider_for).and_return(provider) + allow(provider).to receive(:collect_runtime).and_raise(NilKill::Languages::UnsupportedRuntimeTracer, "no tracer") + + expect { capture_io { described_class.new(["--language", "go", "--", "go", "test"]).run } }.to raise_error(SystemExit) + end + end + + describe NilKill::Commands::AnalyzeCommand do + it "requires normalized v2 evidence and writes analyzed actions" do + Dir.mktmpdir do |dir| + evidence_path = File.join(dir, "evidence.json") + output_path = File.join(dir, "out", "evidence.json") + evidence = NilKill::Schema::EvidenceBundle.build(root: dir, static: {}, runtime: {}) + File.write(evidence_path, JSON.pretty_generate(evidence)) + + analyzer = instance_double(NilKill::Analyzers::RuntimeEvidenceAnalyzer, analyze: [{"kind" => "fix_sig_return"}]) + expect(NilKill::Analyzers::RuntimeEvidenceAnalyzer).to receive(:new).and_return(analyzer) + + out, = capture_io do + described_class.new(["--evidence=#{evidence_path}", "--output", output_path]).run + end + + written = JSON.parse(File.read(output_path)) + expect(written["actions"]).to eq([{"kind" => "fix_sig_return"}]) + expect(out).to include("wrote analyzed evidence") + end + end + end + + describe NilKill::Commands::CollectPythonCommand do + it "is a python-specific wrapper around collect-runtime" do + runner = instance_double(NilKill::Commands::CollectRuntimeCommand, run: true) + expect(NilKill::Commands::CollectRuntimeCommand).to receive(:new).with(["--language", "python", "--", "pytest"]).and_return(runner) + + described_class.new(["--", "pytest"]).run + end + end + + describe NilKill::Commands::TraceSpecCommand do + it "publishes the runtime event schema and current language capabilities" do + spec = described_class.new([]).spec + expect(spec["required_common_fields"]).to include("schema_version", "language", "payload") + expect(spec["required_minimum_events"]).to include("method_call", "coverage") + expect(spec["language_capabilities"].map { |capability| capability["language"] }).to include("python") + end + end + + describe NilKill::Languages::Providers::Python do + it "builds a Python tracing environment and honors append mode" do + provider = described_class.new + + Dir.mktmpdir do |dir| + output = File.join(dir, "trace") + FileUtils.mkdir_p(output) + stale = File.join(output, "old.jsonl") + File.write(stale, "old") + + expect(provider).to receive(:system) do |env, *cmd, chdir:| + expect(env).to include( + "NIL_KILL_PY_TRACE" => "1", + "NIL_KILL_PY_TRACE_OUT" => output, + "NIL_KILL_TRACE_ROOT" => dir, + ) + expect(env["NIL_KILL_TARGETS"].split(File::PATH_SEPARATOR)).to eq([File.join(dir, "src")]) + expect(env["PYTHONPATH"]).to include("gems/nil-kill/lib") + expect(cmd).to eq(["python", "-m", "pytest"]) + expect(chdir).to eq(dir) + true + end + + out, = capture_io do + provider.collect_runtime( + argv: ["--", "python", "-m", "pytest"], + root: dir, + output: output, + targets: ["src"], + append: false + ) + end + + expect(File).not_to exist(stale) + expect(out).to include("wrote Python trace events") + end + end + end + + describe NilKill::HashShapeOps do + it "deep-copies, merges, stringifies, and preserves poisoned shapes" do + left = { + "keys" => { :id => ["Integer"] }, + "value_hash_shapes" => { :meta => { "keys" => { :name => ["String"] }, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false } }, + "value_array_element_shapes" => {}, + "poisoned" => false, + } + right = { + "keys" => { :id => ["Integer"], :active => ["TrueClass"] }, + "value_hash_shapes" => { :meta => { "keys" => { :name => ["String"], :age => ["Integer"] }, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false } }, + "value_array_element_shapes" => { :items => { "keys" => { :sku => ["String"] }, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false } }, + "poisoned" => false, + } + + copy = described_class.dup_shape(left, stringify_keys: true) + expect(copy["keys"]).to eq("id" => ["Integer"]) + copy["keys"]["id"] << "String" + expect(left["keys"][:id]).to eq(["Integer"]) + + merged = described_class.merge_shapes(left, right, stringify_keys: true) + expect(merged["keys"]).to include("id" => ["Integer"], "active" => ["TrueClass"]) + expect(merged.dig("value_hash_shapes", "meta", "keys")).to include("name" => ["String"], "age" => ["Integer"]) + expect(merged.dig("value_array_element_shapes", "items", "keys")).to eq("sku" => ["String"]) + expect(described_class.merge_shapes(left, described_class.poisoned_shape)).to eq(described_class.poisoned_shape) + end + end + + describe NilKill::Languages::Providers::Ruby::Sorbet do + it "extracts only real Sorbet signatures and RBI field types" do + provider = described_class.new + function = double("function", signature: "sig { returns(String) }") + expect(provider.method_source(function)).to include("type_system" => "sorbet", "sig" => "sig { returns(String) }") + expect(provider.method_source(double("function", signature: "returns(String)"))).to eq({}) + + Dir.mktmpdir do |dir| + rbi = File.join(dir, "sorbet", "rbi", "models.rbi") + FileUtils.mkdir_p(File.dirname(rbi)) + File.write(rbi, <<~RBI) + class User + sig { returns(String) } + def name; end + def dynamic; end + end + RBI + + expect(provider.field_type_index(root: dir)).to include(["User", "name"] => "String", ["User", "dynamic"] => "T.untyped") + end + end + end + + describe NilKill::StructRBI do + let(:evidence) do + { + "facts" => { + "struct_declarations" => [ + { "class" => "User", "line" => 1, "fields" => ["name", "age"] }, + ], + "struct_field_runtime" => [ + { "class" => "User", "field" => "name", "types" => ["String"] }, + ], + "struct_field_static" => [ + { "class" => "User", "field" => "age", "type" => "Integer" }, + ], + }, + } + end + + before do + allow(NilKill::Store).to receive(:read).and_return(evidence) + end + + it "generates complete RBI using candidates, existing RBI types, and validation blocklists" do + report = instance_double(NilKill::Report, struct_field_candidates: [ + { "class" => "User", "field" => "name", "type" => "String" }, + { "class" => "User", "field" => "age", "type" => "Integer" }, + ]) + allow(NilKill::Report).to receive(:new).and_return(report) + + rbi = described_class.new(["--complete"]).tap do |generator| + allow(generator).to receive(:existing_rbi_types).and_return(["User", "age"] => "Integer") + end + + text = rbi.generate + expect(text).to include("class User", "def name; end", "returns(String)", "def age; end", "returns(Integer)") + + rbi.instance_variable_set(:@blocklist, Set["age"]) + expect(rbi.generate).to include("def age; end", "returns(T.untyped)") + end + + it "extracts offending methods from multiple Sorbet diagnostic shapes" do + Dir.mktmpdir do |dir| + out = File.join(dir, "structs.rbi") + File.write(out, "class User\n sig { returns(String) }\n def name; end\nend\n") + generator = described_class.new(["--output", out]) + text = <<~SRB + Got `String` originating from: + app/models/user.rb:1: user.name + app.rb:2: Expected `String` but found `Integer` for argument `age` https://srb.help/7002 + 2 | user.age + #{out}:2: Method result type does not match https://srb.help/7005 + SRB + + expect(generator.extract_offending_methods(text)).to include("name", "age") + end + end + + it "runs print/output generation paths and honors existing RBI slots" do + report = instance_double(NilKill::Report, struct_field_candidates: [ + { "class" => "User", "field" => "name", "type" => "String" }, + { "class" => "User", "field" => "age", "type" => "Integer" }, + ]) + allow(NilKill::Report).to receive(:new).and_return(report) + + printed, = capture_io do + generator = described_class.new([]) + allow(generator).to receive(:existing_rbi_types).and_return(["User", "age"] => "Integer") + generator.run + end + expect(printed).to include("def name; end") + expect(printed).not_to include("def age; end") + + Dir.mktmpdir("nil-kill-struct-rbi", NilKill::ROOT) do |dir| + out = File.join(dir, "out", "structs.rbi") + generator = described_class.new(["--output", out, "--include-existing-rbi"]) + allow(generator).to receive(:existing_rbi_types).and_return(["User", "age"] => "Integer") + + output, = capture_io { generator.run } + + expect(output).to include("wrote") + expect(File.read(out)).to include("def name; end", "def age; end") + expect(generator.existing_rbi_slots).to include(["User", "age"]) + end + end + + it "iteratively validates generated RBI and restores on non-convergence" do + report = instance_double(NilKill::Report, struct_field_candidates: [ + { "class" => "User", "field" => "name", "type" => "String" }, + ]) + allow(NilKill::Report).to receive(:new).and_return(report) + + Dir.mktmpdir("nil-kill-struct-validate", NilKill::ROOT) do |dir| + out = File.join(dir, "structs.rbi") + generator = described_class.new(["--complete", "--validate", "--output", out, "--validate-max-iters", "3"]) + calls = 0 + allow(Open3).to receive(:capture3) do + calls += 1 + if calls == 1 + ["app.rb:1: Expected `String` for argument `name`\n 1 | user.name\n", "", status(false)] + else + ["", "", status(true)] + end + end + + stdout, stderr = capture_io { generator.run } + + expect(stdout).to include("validated clean") + expect(stderr).to include("dropping 1 offending method") + expect(generator.instance_variable_get(:@blocklist)).to include("name") + end + + Dir.mktmpdir("nil-kill-struct-validate-fail", NilKill::ROOT) do |dir| + out = File.join(dir, "structs.rbi") + File.write(out, "original") + generator = described_class.new(["--complete", "--validate", "--output", out, "--validate-max-iters", "1"]) + allow(Open3).to receive(:capture3).and_return(["unparseable sorbet output\n", "", status(false)]) + + expect { capture_io { generator.run } }.to raise_error(RuntimeError, /validate failed/) + expect(File.read(out)).to eq("original") + end + end + end + + describe NilKill::FlowGraph do + it "queries reachability, labels hash-record origins, and imports runtime observations" do + graph = described_class.new + graph.add_node(:source, "a", types: ["String"]) + graph.add_edge(:unknown_edge_kind, "a", "b", "line" => 1, "code" => "a") + graph.add_edge(:unknown_edge_kind, "a", "b", "line" => 1, "code" => "a") + graph.add_edge(:call_argument, "b", "c") + + expect(graph.outgoing("a").size).to eq(1) + expect(graph.incoming("b").size).to eq(1) + expect(graph.reachable?("a", "c")).to be(true) + expect(graph.reachable?("a", "c", edge_kinds: [:hash_read])).to be(false) + expect(graph.to_h.fetch("edges").map { |edge| edge["kind"] }).to include("assignment", "call_argument") + + lookups = [ + { "origin" => { "kind" => "method parameter", "path" => "src/a.rb", "line" => 3, "name" => "record" }, "index" => ":name" }, + { "origin" => { "kind" => "method parameter", "name" => "record" }, "index" => ":name" }, + { "origin" => { "kind" => "array literal", "path" => "src/a.rb", "line" => 4, "name" => "records" }, "index" => "'id'" }, + { "origin" => { "kind" => "forwarded return", "path" => "src/a.rb", "line" => 5, "callee" => "load_record" }, "index" => ":id" }, + { "origin" => { "kind" => "instance variable", "name" => "@record" }, "index" => ":state" }, + { "path" => "src/a.rb", "line" => 8, "receiver" => "", "index" => "dynamic" }, + { "path" => "src/a.rb", "line" => 9, "receiver" => "local", "enclosing_scope" => "A#call", "index" => ":name" }, + ] + + labels = lookups.map { |lookup| graph.hash_record_label_for_lookup(lookup) } + expect(labels).to include("method parameter hash record record", "unknown hash record") + expect(graph.hash_record_identity_for_lookup(lookups.first)).to end_with("[:name]") + expect(graph.hash_record_identity_for_lookup(lookups.first, include_field: false)).not_to end_with("[:name]") + + source = { "path" => "src/a.rb", "line" => 20, "class" => "A", "method" => "call", "kind" => "instance", "params" => [{ "name" => "items", "type" => "T::Array[T.untyped]" }], "sig" => "sig { params(items: T::Array[T.untyped]).returns(T.untyped) }" } + evidence = { + "facts" => { + "existing_sigs" => [source], + "unsigned_methods" => [], + "return_origins" => [ + source.merge("return_syntax" => "explicit", "candidate_type" => "String", "sources" => [ + { "kind" => "static", "type" => "String", "line" => 21, "code" => '"ok"' }, + { "kind" => "call_untyped", "callee" => "helper", "line" => 22, "code" => "helper" }, + ]), + ], + "param_origins" => [ + { "path" => "src/a.rb", "line" => 30, "callee" => "call", "arg_kind" => "positional", "slot" => "0", "origin_kind" => "typed_return", "source_method" => "builder", "type" => "T::Array[String]", "code" => "call(builder)" }, + ], + "collection_index_lookups" => lookups, + "struct_field_static" => [ + { "path" => "src/a.rb", "line" => 40, "class" => "A::Record", "field" => "name", "type" => "String", "expression" => '"Ada"' }, + ], + "collection_runtime" => [ + { "path" => "src/a.rb", "line" => 20, "owner_kind" => "method_param", "name" => "items", "kind" => "array", "elem_classes" => ["String"], "type" => "String" }, + ], + }, + "methods" => [ + { + "source" => source, + "calls" => 2, + "returns" => ["String"], + "params_by_name" => { "items" => ["Array"] }, + }, + ], + } + + imported = described_class.from_evidence(evidence) + expect(imported.edges).to include(a_hash_including("kind" => "explicit_return")) + expect(imported.edges).to include(a_hash_including("kind" => "call_result", "from" => "return:method_name:builder")) + expect(imported.types_for("runtime_collection:method_param:src/a.rb:20:items:array")).to include("String") + end + end + + describe Decomplex::Native::Command do + it "uses an explicit fresh native binary and returns stdout on success" do + Dir.mktmpdir do |dir| + bin = File.join(dir, "decomplex-rust") + File.write(bin, "#!/bin/sh\n") + File.chmod(0o755, bin) + + isolated_env("DECOMPLEX_RUST_BIN" => bin) do + expect(Open3).to receive(:capture3).with(bin, "co-update").and_return(["ok", "", status(true)]) + expect(described_class.run("co-update")).to eq("ok") + end + end + end + + it "falls back to cargo, validates jobs, and surfaces native failures" do + isolated_env("DECOMPLEX_RUST_BIN" => nil) do + expect(described_class.jobs_args(nil)).to eq([]) + expect(described_class.jobs_args("2")).to eq(["--jobs", "2"]) + expect { described_class.jobs_args("0") }.to raise_error(ArgumentError, /greater than zero/) + + expect(Open3).to receive(:capture3).and_return(["stdout failure", "", status(false)]) + expect { described_class.run("predicate-aliases") }.to raise_error(RuntimeError, /stdout failure/) + + expect(Open3).to receive(:capture3).and_raise(Errno::ENOENT.new("cargo")) + expect { described_class.run("co-update") }.to raise_error(RuntimeError, /requires cargo/) + end + end + end + + describe "Decomplex native wrapper normalization" do + it "validates Ruby files and parses co-update and predicate-alias JSON" do + expect(Decomplex::Native::Command).to receive(:run) + .with("co-update", "--language", "ruby", "--jobs", "3", "a.rb") + .and_return(JSON.dump("co_written_pairs" => [])) + expect(Decomplex::Native::CoUpdate.scan(["a.rb"], jobs: 3)).to eq("co_written_pairs" => []) + + expect(Decomplex::Native::Command).to receive(:run) + .with("predicate-aliases", "--language", "ruby", "a.rb") + .and_return(JSON.dump("alias_clusters" => [])) + expect(Decomplex::Native::PredicateAliases.scan(["a.rb"])).to eq("alias_clusters" => []) + + expect { Decomplex::Native::CoUpdate.scan(["a.py"]) }.to raise_error(ArgumentError, /Ruby files only/) + expect { Decomplex::Native::PredicateAliases.scan(["a.py"]) }.to raise_error(ArgumentError, /Ruby files only/) + end + + it "normalizes flay-similarity clone types and span coordinates" do + expect(Decomplex::Native::Command).to receive(:run) + .with("flay-similarity", "--language", "ruby", "--mass", "5", "--fuzzy", "2", "a.rb") + .and_return(JSON.dump([ + { + "clone_type" => "exact", + "spans" => { "a.rb" => ["1", "4"] }, + }, + ])) + + findings = Decomplex::Native::FlaySimilarity.scan(["a.rb"], mass: "5", fuzzy: "2") + expect(findings).to eq([{ clone_type: :exact, spans: { :"a.rb" => [1, 4] } }]) + expect { Decomplex::Native::FlaySimilarity.scan(["a.py"], mass: 5, fuzzy: 2) }.to raise_error(ArgumentError, /Ruby files only/) + end + end + + describe NilKillRuntimeTrace do + def reset_runtime_trace_state! + FileUtils.rm_f(described_class::TRACE_PLAN_PATH) + { + methods: {}, + tlets: {}, + structs: {}, + ivar_runtime: {}, + tuples: {}, + collections: {}, + method_edges: {}, + objects: {}, + object_tokens: {}, + frames: Hash.new { |h, k| h[k] = [] }, + shape_lookup: {}, + path_cache: {}, + target_cache: {}, + site_ctx: {}, + cshape: {}, + ctsk: {}, + cls_name: {}, + method_metadata: {}, + planned_methods_by_class: nil, + targeted_tracepoints: [], + targeted_tracepoint_keys: Set.new, + trace_plan_loaded: false, + trace_plan: nil, + coverage_line_map: nil, + pending_mut: nil, + coalesce: true, + coverage_owned: false, + }.each do |name, value| + described_class.instance_variable_set(:"@#{name}", value) + end + end + + FakeTracePoint = Struct.new( + :path, + :lineno, + :defined_class, + :method_id, + :parameters, + :return_value, + :raised_exception, + :trace_binding, + keyword_init: true + ) do + def binding + trace_binding + end + end + + def binding_for_forced_args(args) + binding + end + + before do + reset_runtime_trace_state! + end + + it "filters trace plans and respects sampling gates" do + target = described_class::TARGETS.first + file = File.join(target, "trace_plan_unit.rb") + plan = { + "target_dirs" => described_class::TARGETS, + "methods" => { + ["Worker", "skip", "instance", file, 10].join("\0") => { "sample" => false, "frame" => false }, + ["Worker", "perform", "instance", file, 20].join("\0") => { + "sample" => true, + "frame" => true, + "return" => false, + "params" => { "payload" => true, "ignored" => false }, + }, + }, + "tracepoint_methods" => { + ["Worker", "targeted", "class", file, 30].join("\0") => { + "sample" => true, + "frame" => false, + "return" => true, + "params" => { "arg" => true }, + }, + }, + "tlets" => { [file, 40].join("\0") => true }, + "struct_fields" => { ["User", "name"].join("\0") => true }, + } + FileUtils.mkdir_p(File.dirname(described_class::TRACE_PLAN_PATH)) + File.write(described_class::TRACE_PLAN_PATH, JSON.dump(plan)) + + expect(described_class.trace_plan).to eq(plan) + expect(described_class.method_plan("Worker", "perform", "instance", file, 20)).to include("return" => false) + expect(described_class.sample_param?(plan["methods"].values.last, "payload")).to be(true) + expect(described_class.sample_param?(plan["methods"].values.last, "ignored")).to be(false) + expect(described_class.sample_return?(plan["methods"].values.last)).to be(false) + expect(described_class.sample_tlet?(file, 40)).to be(true) + expect(described_class.sample_struct_field?("Models::User", "name")).to be(true) + expect(described_class.sample_struct_field?("Models::User", "missing")).to be(false) + + planned = described_class.planned_methods_by_class + expect(planned["Worker"].map { |entry| entry[:method_id] }).to eq(["targeted"]) + end + + it "normalizes paths, target membership, class names, and collection shapes" do + target_file = File.join(described_class::TARGETS.first, "shape_unit.rb") + outside = File.join(Dir.tmpdir, "outside.rb") + + expect(described_class.abs_path(target_file)).to eq(File.expand_path(target_file, described_class::ROOT)) + expect(described_class.target_path?(target_file)).to be(true) + expect(described_class.target_path?(outside)).to be(false) + expect(described_class.class_name(nil)).to eq("NilClass") + expect(described_class.class_name("x")).to eq("String") + + array_shape = described_class.container_shape([1, 2, 3]) + expect(array_shape.first).to eq(:array) + expect(array_shape[1].to_a).to eq(["Integer"]) + expect(array_shape).to be_frozen + + hash_shape = described_class.container_shape({ "id" => 1 }) + expect(hash_shape.first).to eq(:hash) + expect(hash_shape[1][0].to_a).to eq(["String"]) + expect(hash_shape[1][1].to_a).to eq(["Integer"]) + + nested_key = described_class.collection_type_shape_key([{ "id" => 1 }]) + expect(described_class.shape_payload(nested_key)).to include("kind" => "array") + expect(described_class.container_shape("scalar")).to be_nil + end + + it "records source-wrapper calls, returns, raises, ivars, structs, and tuples" do + target_file = File.join(described_class::TARGETS.first, "record_unit.rb") + FileUtils.mkdir_p(File.dirname(target_file)) + File.write(target_file, "# trace target\n") + + described_class.record_source_method_call("Worker", "perform", "instance", target_file, 12, { + "payload" => ["a", "b"], + "meta" => { "id" => 1 }, + }) + returned = described_class.record_source_method_return("Worker", "perform", "instance", target_file, 12, ["ok", 1]) + expect(returned).to eq(["ok", 1]) + + key = ["Worker", "perform", "instance", File.expand_path(target_file, described_class::ROOT), 12] + bucket = described_class.methods.fetch(key) + expect(bucket[:calls]).to eq(1) + expect(bucket[:ok_calls]).to eq(1) + expect(bucket[:params_by_name]["payload"].to_a).to eq(["Array"]) + expect(bucket[:return_elem].to_a).to contain_exactly("Integer", "String") + + described_class.record_source_method_call("Worker", "explode", "instance", target_file, 30, {}) + described_class.record_source_method_raise("Worker", "explode", "instance", target_file, 30, RuntimeError.new("boom")) + error_key = ["Worker", "explode", "instance", File.expand_path(target_file, described_class::ROOT), 30] + expect(described_class.methods.fetch(error_key)[:raised].to_a).to eq(["RuntimeError"]) + + receiver = Class.new + stub_const("TraceReceiver", receiver) + described_class.record_ivar_assignment(receiver.new, "@items", [1, 2], target_file, 44) + expect(described_class.instance_variable_get(:@ivar_runtime).keys.first).to eq(["TraceReceiver", "@items"]) + + struct_class = Class.new + struct_class.instance_variable_set(:@__nil_kill_struct_path, target_file) + struct_class.instance_variable_set(:@__nil_kill_struct_line, 50) + described_class.record_struct_field(struct_class, "TraceStruct", "values", [1, 2, 3]) + struct_key = ["TraceStruct", "values", File.expand_path(target_file, described_class::ROOT), 50] + expect(described_class.structs.fetch(struct_key)[:array_calls]).to eq(1) + expect(described_class.tuples).not_to be_empty + ensure + FileUtils.rm_f(target_file) + end + + it "records forced tracepoint calls, returns, raises, and method edges" do + target_file = File.join(described_class::TARGETS.first, "forced_trace_unit.rb") + FileUtils.mkdir_p(File.dirname(target_file)) + File.write(target_file, "# trace target\n") + entry = { + owner: "TraceTarget", + kind: "instance", + method_id: "forced", + path: target_file, + line: 8, + params: { "items" => true, "meta" => true }, + sample: true, + frame: true, + return: true, + } + + call_tp = FakeTracePoint.new( + path: target_file, + lineno: 8, + defined_class: Class.new, + method_id: :forced, + parameters: [[:req, :items], [:req, :meta]], + trace_binding: binding_for_forced_args([["a", "b"], { "id" => 1 }]) + ) + described_class.record_call(call_tp, forced_entry: entry) + + return_tp = FakeTracePoint.new(path: target_file, lineno: 8, method_id: :forced, return_value: { "ok" => [1, 2] }) + described_class.record_return(return_tp, forced_entry: entry) + + key = ["TraceTarget", "forced", "instance", File.expand_path(target_file, described_class::ROOT), 8] + bucket = described_class.methods.fetch(key) + expect(bucket[:calls]).to eq(1) + expect(bucket[:ok_calls]).to eq(1) + expect(bucket[:param_elem]["items"].to_a).to eq(["String"]) + expect(bucket[:return_kv][0].to_a).to eq(["String"]) + expect(bucket[:return_kv_shapes][1].to_a.map { |shape| described_class.shape_payload(shape)["kind"] }).to include("array") + + described_class.record_call(call_tp, forced_entry: entry) + raise_tp = FakeTracePoint.new(path: target_file, lineno: 8, method_id: :forced, raised_exception: ArgumentError.new("bad")) + described_class.record_raise(raise_tp, forced_entry: entry) + expect(bucket[:raised_calls]).to eq(1) + expect(bucket[:raised].to_a).to eq(["ArgumentError"]) + + stack = described_class.frames[Thread.current.object_id] + caller_frame = { method_key: ["Caller", "outer", "instance", target_file, 2], edge_key: nil } + callee_frame = { method_key: key, edge_key: nil } + stack << caller_frame + described_class.record_method_edge_entry(callee_frame, stack) + described_class.record_method_edge_outcome(callee_frame, :ok) + expect(described_class.method_edges.values.first[:ok_calls]).to eq(1) + ensure + FileUtils.rm_f(target_file) + end + + it "records collection mutations through Array, Hash, and Set hooks without losing owners" do + target_file = File.join(described_class::TARGETS.first, "collection_hook_unit.rb") + FileUtils.mkdir_p(File.dirname(target_file)) + File.write(target_file, "# trace target\n") + described_class.install_collection_hook + + array_bucket = described_class.method_bucket(["TraceTarget", "with_items", "instance", target_file, 10]) + array = [] + described_class.register_collection_owner(array, owner_kind: "method_param", name: "items", path: target_file, line: 10, bucket: array_bucket) + eval("array.push('a'); array.append('b'); array.unshift(:sym); array[1] = 7; array.concat(['c'], ['d'])", binding, target_file, 20) + + hash_bucket = described_class.method_bucket(["TraceTarget", "with_hash", "instance", target_file, 12]) + hash = {} + described_class.register_collection_owner(hash, owner_kind: "method_param", name: "meta", path: target_file, line: 12, bucket: hash_bucket) + eval("hash['id'] = 1; hash.store(:name, 'Ada'); hash.merge!({'age' => 42}, active: true); hash.update({'score' => 10})", binding, target_file, 30) + + set_bucket = described_class.method_bucket(["TraceTarget", "with_set", "instance", target_file, 14]) + set = Set.new + described_class.register_collection_owner(set, owner_kind: "method_param", name: "tags", path: target_file, line: 14, bucket: set_bucket) + eval("set.add('one'); set << 'two'; set.merge(['three'])", binding, target_file, 40) + + described_class.flush_pending_mutations! + + array_key = ["method_param", "items", File.expand_path(target_file, described_class::ROOT), 10, "array"] + hash_key = ["method_param", "meta", File.expand_path(target_file, described_class::ROOT), 12, "hash"] + set_key = ["method_param", "tags", File.expand_path(target_file, described_class::ROOT), 14, "set"] + expect(described_class.collections.fetch(array_key)[:elem_classes].to_a).to include("String", "Integer", "Symbol") + expect(described_class.collections.fetch(hash_key)[:key_classes].to_a).to include("String", "Symbol") + expect(described_class.collections.fetch(set_key)[:elem_classes].to_a).to include("String") + + described_class.instance_variable_set(:@coalesce, false) + described_class.record_collection_mutation(array, elem: "direct") + expect(described_class.collections.fetch(array_key)[:elem_classes].to_a).to include("String") + + frozen_array = [1, 2].freeze + described_class.register_collection_owner(frozen_array, owner_kind: "method_return", name: "frozen_items", path: target_file, line: 16, bucket: array_bucket) + frozen_key = ["method_return", "frozen_items", File.expand_path(target_file, described_class::ROOT), 16, "array"] + expect(described_class.collections.fetch(frozen_key)[:elem_classes].to_a).to eq(["Integer"]) + ensure + FileUtils.rm_f(target_file) + end + + it "records T.let, OpenStruct, Struct, and Data field hooks at target callsites" do + target_file = File.join(described_class::TARGETS.first, "hook_unit.rb") + FileUtils.mkdir_p(File.dirname(target_file)) + File.write(target_file, "# trace target\n") + + t_module = Module.new do + def self.let(value, _type, **_kw) + value + end + end + stub_const("T", t_module) + described_class.install_tlet_hook + eval("T.let('name', String)", binding, target_file, 7) + expect(described_class.tlets.values.first[:classes].to_a).to eq(["String"]) + + described_class.install_open_struct_hook + eval("OpenStruct.new(name: 'Ada').tap { |obj| obj[:age] = 42 }", binding, target_file, 12) + expect(described_class.structs.keys.map { |key| key[1] }).to include("name", "age") + + struct_class = Struct.new(:name, :tags) + struct_class.instance_variable_set(:@__nil_kill_struct_path, target_file) + struct_class.instance_variable_set(:@__nil_kill_struct_line, 20) + described_class.attach_struct(struct_class) + instance = struct_class.new("Ada", ["ruby"]) + instance.tags = ["coverage"] + instance[:name] = "Grace" + expect(described_class.structs.keys.map { |key| key[0] }).to include("AnonymousStruct") + + if defined?(Data) + data_class = Data.define(:name, :meta) + data_class.instance_variable_set(:@__nil_kill_struct_path, target_file) + data_class.instance_variable_set(:@__nil_kill_struct_line, 30) + described_class.attach_data(data_class) + data_class.new("Ada", { "id" => 1 }) + expect(described_class.structs.keys.map { |key| key[0] }).to include("AnonymousData") + end + ensure + FileUtils.rm_f(target_file) + end + + it "handles invalid trace plans, set shapes, source locations, and coverage line maps" do + target_file = File.join(described_class::TARGETS.first, "line_map_unit.rb") + FileUtils.mkdir_p(File.dirname(target_file)) + File.write(target_file, "# trace target\n") + FileUtils.mkdir_p(File.dirname(described_class::TRACE_PLAN_PATH)) + File.write(described_class::TRACE_PLAN_PATH, "{") + expect(described_class.trace_plan).to be_nil + + set_key = described_class.collection_type_shape_key(Set["a", "b"]) + expect(described_class.shape_payload(set_key)).to include("kind" => "set") + expect(described_class.collection_kind(Set.new)).to eq("set") + expect(described_class.collection_key_for("set", owner_kind: "field", name: "tags", path: target_file, line: 4)).to include("set") + + klass = Class.new do + def initialize; end + end + stub_const("LineMapRuntimeOwner", klass) + expect(described_class.source_location_for_class(klass)).to be_an(Array) + expect(described_class.method_owner(klass)).to include("instance") + expect(described_class.method_owner(nil)).to be_nil + + FileUtils.mkdir_p(described_class::OUT_DIR) + rel = Pathname.new(File.expand_path(target_file, described_class::ROOT)).relative_path_from(Pathname.new(described_class::ROOT)).to_s + File.write(File.join(described_class::OUT_DIR, ".nk-linemap.json"), JSON.dump(rel => { "10" => 3, 10 => 3 })) + described_class.instance_variable_set(:@coverage_line_map, nil) + expect(described_class.src_line(target_file, 10)).to eq(3) + ensure + FileUtils.rm_f(target_file) + end + end + + describe NilKill::Runtime::Normalizer do + it "normalizes raw runtime trace events into evidence without requiring legacy fixtures" do + Dir.mktmpdir("nil-kill-normalizer", NilKill::ROOT) do |dir| + trace_dir = File.join(dir, "trace") + FileUtils.mkdir_p(trace_dir) + trace_file = File.join(trace_dir, "events.jsonl") + source = File.join(dir, "src", "worker.rb") + FileUtils.mkdir_p(File.dirname(source)) + File.write(source, "class Worker\n def call(input); input; end\nend\n") + rel_source = Pathname.new(source).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + method_id = "ruby\0#{rel_source}\0Worker\0instance\0call\02" + static = { + "methods" => [ + { "id" => method_id, "language" => "ruby", "path" => rel_source, "line" => 2, "owner" => "Worker", "name" => "call", "kind" => "instance", "params" => ["input"], "return" => "T.untyped" }, + ], + "fields" => [ + { "language" => "ruby", "path" => rel_source, "owner" => "Worker", "name" => "name" }, + ], + } + events = [ + { "schema_version" => 2, "event" => "method_call", "language" => "ruby", "path" => source, "line" => 2 }, + { "schema_version" => 1, "event" => "process_start", "language" => "ruby", "path" => source, "line" => 1, "run_id" => "run-1", "pid" => 123, "thread_id" => "main", "timestamp_ns" => 10 }, + { "schema_version" => 1, "event" => "method_call", "language" => "ruby", "path" => source, "line" => 2, "method_id" => method_id, "run_id" => "run-1", "payload" => { "sample_count" => 2 } }, + { "schema_version" => 1, "event" => "param_observed", "language" => "ruby", "path" => source, "line" => 2, "method_id" => method_id, "run_id" => "run-1", "payload" => { "param" => "input", "type" => "String", "sample_count" => 2 } }, + { "schema_version" => 1, "event" => "method_return", "language" => "ruby", "path" => source, "line" => 2, "method_id" => method_id, "run_id" => "run-1", "payload" => { "type" => { "name" => "String", "kind" => "primitive" } } }, + { "schema_version" => 1, "event" => "method_raise", "language" => "ruby", "path" => source, "line" => 2, "method_id" => method_id, "run_id" => "run-1", "payload" => { "class" => "ArgumentError" } }, + { "schema_version" => 1, "event" => "field_observed", "language" => "ruby", "path" => source, "line" => 3, "run_id" => "run-1", "payload" => { "owner" => "Worker", "field" => "name", "type" => "String" } }, + { "schema_version" => 1, "event" => "collection_observed", "language" => "ruby", "path" => source, "line" => 4, "run_id" => "run-1", "payload" => { "owner" => "Worker", "name" => "items", "kind" => "array", "element_types" => ["String"] } }, + { "schema_version" => 1, "event" => "hash_shape_observed", "language" => "ruby", "path" => source, "line" => 5, "run_id" => "run-1", "payload" => { "owner" => "Worker", "name" => "meta", "shape" => { "keys" => { "id" => ["Integer"] } } } }, + { "schema_version" => 1, "event" => "call_edge", "language" => "ruby", "path" => source, "line" => 2, "run_id" => "run-1", "payload" => { "caller" => { "method_id" => method_id }, "callee" => { "language" => "ruby", "path" => source, "line" => 20, "owner" => "Worker", "name" => "missing", "kind" => "instance" } } }, + { "schema_version" => 1, "event" => "coverage", "language" => "ruby", "path" => source, "line" => 2, "run_id" => "run-1", "payload" => { "lines" => [1, 2, 3] } }, + { "schema_version" => 1, "event" => "mystery", "language" => "ruby", "path" => source, "line" => 9, "run_id" => "run-1" }, + { "schema_version" => 1, "event" => "process_end", "language" => "ruby", "path" => source, "line" => 1, "run_id" => "run-1", "pid" => 123, "thread_id" => "main", "timestamp_ns" => 99 }, + ] + File.write(trace_file, events.map { |event| JSON.generate(event) }.join("\n") + "\nnot-json\n[]\n") + + evidence = described_class.new(root: NilKill::ROOT).normalize(static: static, trace_paths: [trace_dir], analyze: false) + expect(evidence.dig("runtime", "method_hits", method_id, "calls")).to eq(2) + expect(evidence.dig("runtime", "method_hits", method_id, "ok_calls")).to eq(1) + expect(evidence.dig("runtime", "param_observations", method_id, "input", "types").first).to include("name" => "String") + expect(evidence.dig("runtime", "return_observations", method_id, "types").first).to include("display" => "String") + expect(evidence.dig("runtime", "exceptions", method_id, "exceptions")).to eq(["ArgumentError"]) + expect(evidence.dig("runtime", "coverage", rel_source)).to eq([1, 2, 3]) + expect(evidence["diagnostics"].map { |diagnostic| diagnostic["code"] }).to include("unsupported_trace_schema", "unknown_trace_event", "invalid_json", "not_raw_trace_event", "unresolved_method_locator") + end + end + end + + describe NilKill::Infer do + it "delegates inference to Rust and parses Sorbet diagnostics into structured evidence" do + infer = described_class.new([]) + allow(infer).to receive(:rust_binary_path).and_return("nil-kill-infer-rust") + expect(Open3).to receive(:capture3) do |bin, input_path, output_path| + expect(bin).to eq("nil-kill-infer-rust") + expect(JSON.parse(File.read(input_path))).to include("facts") + File.write(output_path, JSON.dump( + "actions" => [{ "kind" => "fix_sig_return" }], + "diagnostics" => { "rust" => [{ "message" => "ok" }] } + )) + ["", "", status(true)] + end + infer.delegate_to_rust("facts" => {}) + expect(infer.store.actions).to eq([{ "kind" => "fix_sig_return" }]) + expect(infer.store.diagnostics["rust"]).to eq([{ "message" => "ok" }]) + + sorbet = <<~ERR + \e[31mapp/user.rb:10: Expected `String` but found `Integer` for argument `name` https://srb.help/7002\e[0m + app/caller.rb:3: + app/user.rb:11: Expected `String` but found `NilClass` for method result type https://srb.help/7005 + app/user.rb:11: + app/user.rb:12: Method `foo` does not exist on `NilClass` component https://srb.help/7034 + app/user.rb:12: + app/user.rb:13: Some other error https://srb.help/9999 + NilClass + app/source.rb:7: + ERR + expect(Open3).to receive(:capture3).and_return(["", sorbet, status(false)]) + infer.load_sorbet + expect(infer.store.diagnostics["sorbet_errors"].map { |error| error["code"] }).to include("7002", "7005", "7034", "9999") + expect(infer.store.diagnostics["nil_origins"]).to include({ "origin" => "app/source.rb:7", "count" => 1 }) + expect(infer.store.diagnostics["sorbet_feedback"].map { |item| item["code"] }).to contain_exactly("7002", "7005", "7034") + end + + it "indexes SourceIndex facts when the legacy index engine is requested" do + infer = described_class.new(["--no-sorbet"]) + target = File.join(NilKill::ROOT, "src", "indexed.rb") + usage = File.join(NilKill::ROOT, "tools", "usage.rb") + fake_index = Struct.new( + :rel, + :summary, + :methods, + :tlet_sites, + :dead_nil_checks, + :deterministic_guards, + :struct_declarations, + :struct_field_static, + :tuple_arrays, + :hash_shapes, + :collection_index_lookups, + :hash_record_blockers, + :hash_record_member_calls, + :type_normalizers, + :dispatcher_inferences, + :return_origins, + :param_origins, + :hash_record_escape_sites, + :hidden_enum_observations, + :return_usage_sites, + :return_direct_usage_sites, + :rescue_handlers, + :ivar_protocols, + :ivar_param_origins, + keyword_init: true + ).new( + rel: "src/indexed.rb", + summary: { "methods" => 2 }, + methods: [ + { "path" => "src/indexed.rb", "line" => 1, "class" => "Indexed", "method" => "typed", "kind" => "instance", "has_sig" => true }, + { "path" => "src/indexed.rb", "line" => 2, "class" => "Indexed", "method" => "raw", "kind" => "instance", "has_sig" => false }, + ], + tlet_sites: [{ "line" => 3 }], + dead_nil_checks: [{ "line" => 4 }], + deterministic_guards: [{ "line" => 5 }], + struct_declarations: [{ "class" => "Structy" }], + struct_field_static: [{ "field" => "name" }], + tuple_arrays: [{ "line" => 6 }], + hash_shapes: [{ "line" => 7 }], + collection_index_lookups: [{ "line" => 8 }], + hash_record_blockers: [{ "line" => 9 }], + hash_record_member_calls: [{ "line" => 10 }], + type_normalizers: [{ "line" => 11 }], + dispatcher_inferences: [{ "line" => 12 }], + return_origins: [{ "line" => 13 }], + param_origins: [{ "line" => 14 }], + hash_record_escape_sites: [{ "line" => 15 }], + hidden_enum_observations: [{ "line" => 16 }], + return_usage_sites: [{ "name" => "typed" }], + return_direct_usage_sites: [{ "name" => "raw" }], + rescue_handlers: [{ "line" => 17 }], + ivar_protocols: { ["Indexed", "@state"] => Set["ready"] }, + ivar_param_origins: { ["Indexed", "@state"] => Set[{ "type" => "String" }] } + ) + + isolated_env("NIL_KILL_SOURCE_INDEX_ENGINE" => "source_index", "NIL_KILL_IDX_WARM_ONLY" => "0", "NIL_KILL_IDX_REUSE" => "0") do + allow(NilKill).to receive(:target_files).and_return([target]) + allow(NilKill).to receive(:usage_scan_files).and_return([target, usage]) + allow(NilKill::SourceIndex).to receive(:reset_global_shape_indexes) + allow(NilKill::SourceIndex).to receive(:noreturn_methods).and_return([]) + allow(NilKill::SourceIndex).to receive(:new).and_return(fake_index) + infer.index_sources + end + + facts = infer.store.facts + expect(facts["files"]).to include("src/indexed.rb" => { "methods" => 2 }) + expect(facts["unsigned_methods"].map { |method| method["method"] }).to eq(["raw"]) + expect(facts["existing_sigs"].map { |method| method["method"] }).to eq(["typed"]) + expect(facts["ivar_protocols"]["Indexed\0@state"]).to eq(["ready"]) + expect(facts["return_usage_sites"].size).to be >= 1 + expect(facts["rescue_handlers"].size).to be >= 1 + end + end + + describe NilKill::Report do + def rich_report_evidence(tmp_dir) + src = File.join(tmp_dir, "src") + FileUtils.mkdir_p(src) + worker_path = File.join(src, "worker.rb") + File.write(worker_path, <<~RUBY) + class Worker + def consume(input); input.to_s; end + def emit; helper; end + def helper; "value"; end + end + RUBY + rel_worker = Pathname.new(worker_path).relative_path_from(Pathname.new(NilKill::ROOT)).to_s rescue worker_path + + consume = { + "key" => ["Worker", "consume", "instance", worker_path, 2], + "source" => { "path" => rel_worker, "line" => 2, "end_line" => 2, "class" => "Worker", "method" => "consume", "params" => [{ "name" => "input" }], "sig" => "sig { params(input: T.untyped).returns(String) }" }, + "calls" => 8, + "ok_calls" => 8, + "raised_calls" => 0, + "params_by_name" => { "input" => %w[String User] }, + "params_ok" => { "input" => %w[String User] }, + "params_raised" => {}, + "param_sites" => { "input" => { "#{worker_path}:2:String" => 5, "#{worker_path}:2:User" => 3 } }, + "param_sites_ok" => { "input" => { "#{worker_path}:2:String" => 5, "#{worker_path}:2:User" => 3 } }, + "param_traces" => { "input" => { "#{worker_path}:1:String" => 5 } }, + "param_traces_ok" => { "input" => { "#{worker_path}:1:String" => 5 } }, + "param_elem" => {}, + "param_kv" => {}, + "param_elem_shapes" => {}, + "param_kv_shapes" => {}, + "returns" => ["String"], + "return_elem" => [], + "return_kv" => [[], []], + "return_elem_shapes" => [], + "return_kv_shapes" => [[], []], + "raised" => [], + "has_sig" => true, + } + emit = consume.merge( + "key" => ["Worker", "emit", "instance", worker_path, 3], + "source" => { "path" => rel_worker, "line" => 3, "end_line" => 3, "class" => "Worker", "method" => "emit", "params" => [], "sig" => "sig { returns(T.untyped) }" }, + "calls" => 0, + "ok_calls" => 0, + "params_by_name" => {}, + "params_ok" => {}, + "returns" => [] + ) + visitor = consume.merge( + "key" => ["Visitor", "visit", "instance", worker_path, 4], + "source" => { "path" => rel_worker, "line" => 4, "end_line" => 4, "class" => "Visitor", "method" => "visit", "params" => [{ "name" => "node" }], "sig" => "sig { params(node: T.untyped).returns(T.untyped) }" }, + "params_by_name" => { "node" => %w[AST::CallNode AST::SendNode AST::LiteralNode] }, + "params_ok" => { "node" => %w[AST::CallNode AST::SendNode AST::LiteralNode] }, + "returns" => ["NilClass"] + ) + + facts = NilKill::Store.new.facts + facts["existing_sigs"] = [ + consume.dig("source").merge("sig" => "sig { params(input: T.untyped).returns(String) }"), + emit.dig("source").merge("sig" => "sig { returns(T.untyped) }"), + visitor.dig("source").merge("sig" => "sig { params(node: T.untyped).returns(T.untyped) }"), + ] + facts["unsigned_methods"] = [ + { "path" => rel_worker, "line" => 5, "end_line" => 5, "class" => "Worker", "method" => "unsigned", "params" => [{ "name" => "raw" }] }, + ] + facts["collect_coverage"] = { rel_worker => [2, 3, 4, 5] } + facts["type_normalizers"] = [ + { "path" => rel_worker, "line" => 8, "class" => "Worker", "method" => "consume", "code" => "input.is_a?(User) ? input : User.new(input)", "origin_kind" => "param", "origin_name" => "input" }, + { "path" => rel_worker, "line" => 9, "class" => "Worker", "method" => "consume", "code" => "@type_info.is_a?(Type) ? @type_info : Type.new(@type_info)", "origin_kind" => "ivar", "origin_name" => "@type_info" }, + ] + facts["param_origins"] = [ + { "path" => rel_worker, "line" => 20, "callee" => "consume", "slot" => "input", "origin_kind" => "literal", "type" => "String", "code" => "consume('id')" }, + { "path" => rel_worker, "line" => 21, "callee" => "consume", "slot" => "input", "origin_kind" => "typed_return", "source_method" => "emit", "type" => "User", "code" => "consume(user)" }, + ] + facts["return_origins"] = [ + { "path" => rel_worker, "line" => 3, "class" => "Worker", "method" => "emit", "candidate_type" => "String", "sources" => [{ "kind" => "call_untyped", "callee" => "helper", "line" => 3 }], "blockers" => ["untyped callee helper"] }, + ] + facts["return_direct_usage_sites"] = [ + { "name" => "helper", "context" => "return" }, + ] + facts["struct_declarations"] = [ + { "path" => rel_worker, "line" => 30, "class" => "User", "fields" => %w[name age], "field_types" => { "age" => "Integer" } }, + ] + facts["struct_field_runtime"] = [ + { "path" => rel_worker, "line" => 30, "class" => "User", "field" => "name", "classes" => ["String"] }, + ] + facts["struct_field_static"] = [ + { "path" => rel_worker, "line" => 30, "class" => "User", "field" => "age", "type" => "Integer" }, + ] + facts["ivar_runtime"] = [ + { "path" => rel_worker, "line" => 9, "class" => "Worker", "name" => "@type_info", "classes" => ["Type"] }, + ] + facts["hash_shapes"] = [ + { "path" => rel_worker, "line" => 40, "class" => "Worker", "method" => "record", "keys" => { "name" => ["String"], "age" => ["Integer"] }, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false }, + ] + facts["collection_index_lookups"] = [ + { "path" => rel_worker, "line" => 41, "receiver" => "record", "key" => "name", "receiver_origin" => "local_record" }, + ] + facts["hash_record_blockers"] = [ + { "path" => rel_worker, "line" => 42, "keys" => %w[name age], "reason" => "dynamic key" }, + ] + facts["hash_record_member_calls"] = [ + { "path" => rel_worker, "line" => 43, "key" => "name", "method" => "upcase" }, + ] + facts["tuple_arrays"] = [ + { "path" => rel_worker, "line" => 50, "types" => %w[String Integer], "complete" => true, "mixed" => true }, + ] + facts["collection_runtime"] = [ + { "path" => rel_worker, "line" => 60, "owner_kind" => "method_return", "name" => "items", "kind" => "Array", "elem_classes" => ["String"], "classes" => ["Array"] }, + ] + facts["hidden_enum_pressure"] = [ + { "path" => rel_worker, "line" => 70, "owner" => "Worker", "method" => "status", "slot" => "state", "kind" => "param", "values" => %w[draft sent], "decision_pressure" => 4, "score" => 12, "confidence" => "high", "suggestion" => "extract enum" }, + ] + facts["fallibility_pressure"] = [ + { "path" => rel_worker, "line" => 80, "label" => "Worker#danger", "score" => 20, "handler_pressure" => 2, "direct_sources" => ["raise"], "fallible_callers" => ["Worker#call"], "runtime" => { "calls" => 10, "raised_calls" => 3, "raised_classes" => ["RuntimeError"], "raised_rate" => 30.0 } }, + ] + facts["dead_nil_checks"] = [ + { "path" => rel_worker, "line" => 90, "code" => "return if input.nil?" }, + ] + facts["deterministic_guards"] = [ + { "path" => rel_worker, "line" => 91, "predicate" => "input.nil?", "outcome" => false, "reason" => "non-nil param" }, + ] + + actions = [ + { "kind" => "nil_param_observed", "confidence" => "review", "path" => rel_worker, "line" => 2, "message" => "nil source", "data" => { "name" => "input", "candidate_type" => "String", "callsites" => { "#{rel_worker}:20:String" => 5 } } }, + { "kind" => "union_observed", "confidence" => "review", "path" => rel_worker, "line" => 2, "message" => "union source", "data" => { "name" => "input", "classes" => %w[String User], "callsites" => { "#{rel_worker}:20:String" => 5 } } }, + { "kind" => "fix_sig_return", "confidence" => "high", "path" => rel_worker, "line" => 3, "message" => "return can be String", "data" => { "type" => "String", "source" => "static_return_origin", "observed_calls" => 3 } }, + { "kind" => "replace_nil_with_default", "confidence" => "high", "path" => rel_worker, "line" => 2, "message" => "replace nil", "data" => { "default" => "\"\"", "target_method" => "consume", "name" => "input", "observed_calls" => 8 } }, + { "kind" => "coverage_gap", "confidence" => "gap", "path" => rel_worker, "line" => 5, "message" => "not exercised", "data" => {} }, + { "kind" => "experimental", "confidence" => "low", "path" => rel_worker, "line" => 6, "message" => "extra confidence", "data" => {} }, + ] + + { + "version" => 1, + "generated_at" => Time.now.utc.iso8601, + "target_dirs" => [src], + "target_exclude_dirs" => [], + "methods" => [consume, emit, visitor], + "tlets" => [], + "facts" => facts, + "diagnostics" => { + "sorbet_errors" => [{ "path" => rel_worker, "line" => 2, "message" => "Expected String", "code" => "7002", "severity" => "warning" }], + "nil_origins" => [{ "origin" => "#{rel_worker}:2", "count" => 2 }], + "sorbet_feedback" => [], + }, + "actions" => actions, + } + end + + def report_for(evidence) + described_class.new([], evidence: evidence).tap do |report| + report.instance_variable_set(:@evidence, evidence) + end + end + + def method_runtime_record(source, calls: 4, params_by_name: {}, params_ok: nil, returns: [], param_elem: {}, param_kv: {}, return_elem: [], return_kv: [[], []], return_elem_shapes: [], return_kv_shapes: [[], []]) + params_ok ||= params_by_name + { + "source" => source, + "calls" => calls, + "ok_calls" => calls, + "raised_calls" => 0, + "params_by_name" => params_by_name, + "params_ok" => params_ok, + "params_raised" => {}, + "param_sites" => params_by_name.transform_values { |classes| classes.to_h { |klass| ["#{source["path"]}:#{source["line"]}:#{klass}", 1] } }, + "param_sites_ok" => {}, + "param_traces" => {}, + "param_traces_ok" => {}, + "param_elem" => param_elem, + "param_kv" => param_kv, + "param_elem_shapes" => {}, + "param_kv_shapes" => {}, + "returns" => returns, + "return_elem" => return_elem, + "return_kv" => return_kv, + "return_elem_shapes" => return_elem_shapes, + "return_kv_shapes" => return_kv_shapes, + "raised" => [], + } + end + + it "renders rich evidence across report sections and SARIF pressure outputs" do + Dir.mktmpdir("nil-kill-rich-report", NilKill::ROOT) do |dir| + evidence = rich_report_evidence(dir) + report_path = File.join(dir, "report.md") + + out, = capture_io do + described_class.new(["--output-path", report_path, "--with-links", "--full"], evidence: evidence).run + end + + expect(out).to include("Project Prioritization") + expect(out).to include("Foreign Scalar Inputs Into Object-Typed Params") + expect(out).to include("Hidden Enum Pressure") + expect(out).to include("Fallibility Pressure") + expect(out).to include("Struct Shape Report") + expect(out).to include("Collection Type Report") + expect(out).to include("Tuple-Like Array Report") + expect(File.read(report_path)).to include("Worker#emit") + + sarif = described_class.new(["--format", "sarif"], evidence: evidence).to_sarif_hash(evidence) + rule_ids = sarif.dig("runs", 0, "tool", "driver", "rules").map { |rule| rule["id"] } + expect(rule_ids).to include("nil-kill.pressure.hidden-enum", "nil-kill.pressure.fallibility") + expect(sarif.dig("runs", 0, "results").map { |result| result["ruleId"] }).to include("nil-kill.action.fix-sig-return") + end + end + + it "renders SARIF, hygiene-only, and v2 evidence modes" do + Dir.mktmpdir("nil-kill-report-modes", NilKill::ROOT) do |dir| + evidence = rich_report_evidence(dir) + sarif_path = File.join(dir, "out", "report.sarif") + out, = capture_io { described_class.new(["--format", "json", "--output-path", sarif_path], evidence: evidence).run } + expect(JSON.parse(out)).to include("version" => "2.1.0") + expect(File).to exist(sarif_path) + + hygiene, = capture_io { described_class.new(["--hygiene"], evidence: evidence).run } + expect(hygiene).to include("Hygiene Overview", "Action Plan Counts") + + v2 = NilKill::Schema::EvidenceBundle.build(root: dir, static: { "methods" => [] }, runtime: { "runs" => [] }) + v2_path = File.join(dir, "v2.md") + v2_out, = capture_io { described_class.new(["--output-path", v2_path], evidence: v2).run } + expect(v2_out).to include("Nil Kill Multi-Language Report") + expect(File.read(v2_path)).to include("Multi-Language") + end + end + + it "classifies report pressure, evidence gaps, collection candidates, and hash-record struct shapes" do + Dir.mktmpdir("nil-kill-report-pressure", NilKill::ROOT) do |dir| + evidence = rich_report_evidence(dir) + src = File.join(dir, "src") + FileUtils.mkdir_p(src) + pressure_path = File.join(src, "pressure.rb") + File.write(pressure_path, <<~RUBY) + class NodeConsumer + def consume_a(node); node; end + def consume_b(node); node; end + def consume_c(node); node; end + def collect(items, meta); [items, meta]; end + def guarded(input); input; end + end + RUBY + rel = Pathname.new(pressure_path).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + facts = evidence["facts"] + facts["existing_sigs"] = [] + evidence["methods"] = [] + + node_classes = %w[AST::SendNode AST::CallNode AST::LiteralNode AST::ConstNode] + 3.times do |idx| + line = idx + 2 + source = { + "path" => rel, + "line" => line, + "end_line" => line, + "class" => "NodeConsumer", + "method" => "consume_#{idx}", + "kind" => "instance", + "params" => [{ "name" => "node", "type" => "T.untyped" }], + "sig" => "sig { params(node: T.untyped).returns(T.untyped) }", + "return_origin" => { + "candidate_type" => "T.untyped", + "sources" => [{ "kind" => "unknown", "code" => "node", "unknown_reasons" => ["local variable node"] }], + "blockers" => [], + }, + } + facts["existing_sigs"] << source + evidence["methods"] << method_runtime_record(source, params_by_name: { "node" => node_classes }, returns: []) + end + + collect_source = { + "path" => rel, + "line" => 5, + "end_line" => 5, + "class" => "NodeConsumer", + "method" => "collect", + "kind" => "instance", + "params" => [ + { "name" => "items", "type" => "T::Array[T.untyped]" }, + { "name" => "meta", "type" => "T::Hash[T.untyped, T.untyped]" }, + ], + "sig" => "sig { params(items: T::Array[T.untyped], meta: T::Hash[T.untyped, T.untyped]).returns(T::Array[T.untyped]) }", + } + facts["existing_sigs"] << collect_source + evidence["methods"] << method_runtime_record( + collect_source, + param_elem: { "items" => ["String"] }, + param_kv: { "meta" => [["String"], ["Integer"]] }, + return_elem: ["String"] + ) + + no_candidate_source = { + "path" => rel, + "line" => 6, + "end_line" => 6, + "class" => "NodeConsumer", + "method" => "empty_items", + "kind" => "instance", + "params" => [{ "name" => "items", "type" => "T::Array[T.untyped]" }], + "sig" => "sig { params(items: T::Array[T.untyped]).returns(String) }", + } + facts["existing_sigs"] << no_candidate_source + evidence["methods"] << method_runtime_record(no_candidate_source, calls: 1) + + guarded_source = { + "path" => rel, + "line" => 7, + "end_line" => 7, + "class" => "NodeConsumer", + "method" => "guarded", + "kind" => "instance", + "params" => [{ "name" => "input", "type" => "T.untyped" }], + "sig" => "sig { params(input: T.untyped).returns(String) }", + } + facts["existing_sigs"] << guarded_source + evidence["methods"] << method_runtime_record(guarded_source, params_by_name: { "input" => ["String", "User"] }, returns: ["String"]) + + facts["param_origins"] = [ + { "path" => rel, "line" => 20, "callee" => "guarded", "slot" => "input", "origin_kind" => "static", "type" => "User", "code" => "guarded(user)" }, + { "path" => rel, "line" => 21, "callee" => "guarded", "slot" => "input", "origin_kind" => "static", "type" => "String", "code" => "guarded('id')" }, + { "path" => rel, "line" => 30, "callee" => "collect", "slot" => "items", "origin_kind" => "unknown", "code" => "items", "unknown_reasons" => ["struct/array/collection value items"] }, + ] + facts["return_origins"] = [ + { "path" => rel, "line" => 2, "class" => "NodeConsumer", "method" => "consume_0", "kind" => "instance", "confidence" => "blocked", "candidate_type" => "T.untyped", "sources" => [{ "kind" => "unknown", "code" => "node", "unknown_reasons" => ["local variable node"] }], "blockers" => [] }, + { "path" => rel, "line" => 5, "class" => "NodeConsumer", "method" => "collect", "kind" => "instance", "confidence" => "strong", "candidate_type" => "T::Array[String]", "sources" => [{ "kind" => "static", "type" => "T::Array[String]" }], "blockers" => [] }, + ] + facts["return_usage_sites"] = [ + { "name" => "consume_0", "context" => "return", "current_method" => "collect" }, + { "name" => "collect", "context" => "value" }, + ] + facts["return_direct_usage_sites"] = [ + { "name" => "consume_0", "context" => "return" }, + { "name" => "collect", "context" => "value" }, + ] + facts["type_normalizers"] = [ + { "path" => rel, "line" => 40, "class" => "NodeConsumer", "method" => "guarded", "code" => "input.is_a?(Type) ? input : Type.new(input)", "origin_kind" => "param", "origin_name" => "input" }, + { "path" => rel, "line" => 41, "class" => "NodeConsumer", "method" => "guarded", "code" => "@type_info.is_a?(Type) ? @type_info : Type.new(@type_info)", "origin_kind" => "ivar", "origin_name" => "@type_info" }, + { "path" => rel, "line" => 42, "class" => "NodeConsumer", "method" => "guarded", "code" => "local.is_a?(Type) ? local : Type.new(local)" }, + ] + facts["ivar_runtime"] = [ + { "path" => rel, "line" => 41, "class" => "NodeConsumer", "name" => "@type_info", "classes" => ["Type"] }, + ] + facts["deterministic_guards"] = [ + { "path" => rel, "line" => 43, "class" => "NodeConsumer", "method" => "guarded", "code" => "input.is_a?(User)", "truth_value" => true, "branch_kind" => "if", "taken_branch" => "then", "reason" => "single producer" }, + ] + facts["collect_coverage"] = { rel => [1, 3, 4, 5, 6] } + facts["tlet_sites"] = [ + { "path" => rel, "line" => 50, "name" => "cache", "tlet" => true, "type" => "T::Array[T.untyped]" }, + { "path" => rel, "line" => 51, "name" => "raw", "candidate_type" => "String" }, + ] + facts["struct_declarations"] = [ + { "path" => rel, "line" => 60, "class" => "User", "fields" => %w[name tags missing values], "field_types" => { "name" => "String" } }, + ] + facts["struct_field_runtime"] = [ + { "path" => rel, "line" => 60, "class" => "User", "field" => "tags", "classes" => ["Array"], "elem_classes" => ["String"], "calls" => 3 }, + { "path" => rel, "line" => 60, "class" => "User", "field" => "values", "classes" => %w[String Integer Symbol Float], "calls" => 4 }, + ] + facts["struct_field_static"] = [ + { "path" => rel, "line" => 60, "class" => "User", "field" => "name", "type" => "String" }, + { "path" => rel, "line" => 60, "class" => "User", "field" => "missing", "type" => "" }, + ] + nested_shape = { "keys" => { "city" => ["String"] }, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false } + facts["hash_shapes"] = [ + { "path" => rel, "line" => 70, "code" => "{name: 'Ada', age: 1, address: {city: 'Reno'}}", "keys" => %w[name age address], "value_types" => %w[String Integer Hash], "value_hash_shapes" => { "address" => nested_shape }, "value_array_element_shapes" => {}, "poisoned" => false }, + { "path" => rel, "line" => 71, "code" => "{name: 'Grace', age: 2, active: true}", "keys" => %w[name age active], "value_types" => %w[String Integer TrueClass], "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false }, + ] + facts["collection_index_lookups"] = [ + { "path" => rel, "line" => 80, "code" => "record[:name]", "receiver" => "record", "index" => ":name", "lookup_type" => "T.untyped", "status" => "unknown receiver", "origin" => { "kind" => "hash literal", "path" => rel, "line" => 70, "code" => "{name: 'Ada', age: 1, address: {city: 'Reno'}}" } }, + { "path" => rel, "line" => 81, "code" => "meta['age']", "receiver" => "@meta", "index" => "'age'", "lookup_type" => "T.untyped", "status" => "typed collection receiver", "receiver_type" => "T::Hash[T.untyped, T.untyped]", "origin" => { "kind" => "instance variable", "name" => "@meta" } }, + ] + facts["hash_record_blockers"] = [ + { "path" => rel, "line" => 82, "origin" => { "kind" => "hash literal", "path" => rel, "line" => 70, "code" => "{name: 'Ada', age: 1, address: {city: 'Reno'}}" }, "reason" => "dynamic key" }, + ] + facts["hash_record_member_calls"] = [ + { "path" => rel, "line" => 83, "field" => "name", "member" => "upcase", "origin" => { "kind" => "hash literal", "path" => rel, "line" => 70, "code" => "{name: 'Ada', age: 1, address: {city: 'Reno'}}" }, "code" => "record[:name].upcase" }, + ] + facts["collection_runtime"] = [ + { "path" => rel, "line" => 5, "owner_kind" => "method_param", "name" => "items", "kind" => "array", "elem_classes" => ["String"], "classes" => ["Array"], "calls" => 3, "mutation_sites" => { "#{rel}:90" => 2 } }, + { "path" => rel, "line" => 5, "owner_kind" => "method_param", "name" => "meta", "kind" => "hash", "key_classes" => ["String"], "value_classes" => ["Integer"], "classes" => ["Hash"], "calls" => 2, "mutation_sites" => {} }, + { "path" => rel, "line" => 52, "owner_kind" => "ivar", "name" => "cache", "kind" => "set", "elem_classes" => ["Symbol"], "classes" => ["Set"], "calls" => 1, "mutation_sites" => {} }, + ] + facts["tuple_arrays"] = [ + { "path" => rel, "line" => 100, "types" => %w[String Integer], "confidence" => "high" }, + ] + facts["tuple_runtime"] = [ + { "path" => File.join(NilKill::ROOT, rel), "line" => 101, "kind" => "return", "slot" => "collect", "types" => %w[String Integer], "size" => 2, "complete" => true, "mixed" => true, "calls" => 2 }, + ] + evidence["actions"] = [ + { "kind" => "fix_sig_param", "confidence" => "high", "path" => rel, "line" => 7, "data" => { "name" => "input", "source" => "static_param_backflow", "type" => "User" } }, + { "kind" => "narrow_generic_return", "confidence" => "review", "path" => rel, "line" => 5, "data" => { "type" => "T::Array[String]" } }, + { "kind" => "add_struct_field_sig", "confidence" => "review", "data" => { "class" => "User", "field" => "tags" } }, + ] + + report = report_for(evidence) + allow(report).to receive(:struct_rbi_types).and_return( + ["User", "name"] => "String", + ["User", "tags"] => "T.untyped", + ["User", "values"] => "T.untyped" + ) + + lines = [] + report.send(:append_union_decomplexity, lines, evidence) + report.send(:append_deterministic_guard_collapse, lines, evidence) + report.send(:append_node_alias_candidates, lines, evidence) + report.send(:append_untyped_evidence_gaps, lines, evidence) + report.send(:append_signature_coverage, lines, evidence, accumulator: described_class::HygieneCountsAcc.new) + report.send(:append_variable_assignment_coverage, lines, evidence, accumulator: described_class::HygieneCountsAcc.new) + report.send(:append_signature_slot_evidence, lines, evidence) + report.send(:append_untyped_cause_table, lines, evidence) + report.send(:append_struct_report, lines, evidence) + report.send(:append_collection_report, lines, evidence) + report.send(:append_tuple_report, lines, evidence) + + text = lines.join("\n") + expect(text).to include("Union Decomplexity", "AstNode", "Struct Field Type Candidates", "Hash Record Struct Candidates") + expect(text).to include("Weak Collection Slots With Runtime Candidates", "Runtime Tuple-Like Array Slots") + expect(report.send(:hash_record_struct_candidates, evidence).first).to include("struct_name") + expect(report.send(:collection_blocker_pressure, evidence, report.send(:collection_signature_slots, evidence))).not_to be_empty + end + end + + it "covers report formatting, bucket, pressure, and type helper edge cases" do + evidence = { + "methods" => [], + "facts" => { + "existing_sigs" => [ + { "path" => "src/a.rb", "line" => 1, "class" => "A", "method" => "call", "kind" => "instance", "params" => [{ "name" => "value", "type" => "T.untyped" }], "sig" => "sig { params(value: T.untyped).returns(T.untyped) }" }, + ], + "unsigned_methods" => [], + "struct_declarations" => [], + "param_origins" => [], + "return_origins" => [], + }, + "actions" => [ + { "kind" => "nil_param_observed", "path" => "src/a.rb", "line" => 1, "data" => { "name" => "value", "candidate_type" => "String", "callsites" => { "src/root.rb:2:String" => 5 } } }, + { "kind" => "union_observed", "path" => "src/a.rb", "line" => 1, "data" => { "name" => "value", "classes" => %w[String User Admin Guest Extra More], "callsites" => { "src/root.rb:2:String" => 5 } } }, + { "kind" => "bad_input_type_candidate", "path" => "src/a.rb", "line" => 1, "data" => { "name" => "value", "candidate_type" => "User", "raised_only_classes" => ["String"], "callsites" => { "src/raised.rb:4:String" => 1 } } }, + ], + } + report = report_for(evidence) + lines = ["# Title", "", "- summary", "## Body"] + Array.new(12) { |i| "- item #{i}" } + + expect(report.send(:prepare_linked_report, lines, full: false).join("\n")).to include("Table of Contents", "more") + expect(report.send(:prepare_linked_report, lines, full: true).join("\n")).to include("
") + expect(report.send(:format_report_line, "#{NilKill::ROOT}/src/a.rb:1 A#call")).to include("src/a.rb") + expect(report.send(:github_anchor, "`A#call` & More")).to eq("acall-more") + expect(report.send(:default_for_type, "T::Hash[String, Integer]")).to eq("{}") + expect(report.send(:proposed_action_text, { "kind" => "fix_sig_param", "data" => { "name" => "value", "type" => "String" } }, nil)).to include("value") + expect(report.send(:action_evidence_text, evidence["actions"].first)).to include("top source") + + buckets = {} + report.send(:record_bucket_example!, buckets, "a", "first") + report.send(:record_bucket_example!, buckets, "a", "first") + expect(report.send(:bucket_examples, buckets)).to eq(["2 slots: first"]) + expect(report.send(:unknown_expression_bucket, ["operation +", "local variable x"])).to include("local variable") + expect(report.send(:unknown_expression_bucket, ["forwarded return foo", "instance variable @x"])).to include("multiple") + expect(report.send(:protocol_strength, %w[to_s inspect custom])).to eq("medium") + expect(report.send(:protocol_hint, { "protocols" => { "value" => { "methods" => %w[foo bar], "aliases" => ["v"], "gaps" => ["unknown alias"] } } }, "value", ["Known"], { "Candidate" => Set["foo", "bar"] })).to include("Candidate") + + pressure = report.send(:merge_pressure, + report.send(:callsite_pressure, evidence["actions"], "nil_param_observed"), + report.send(:callsite_pressure, evidence["actions"], "union_observed"), + report.send(:callsite_pressure, evidence["actions"], "bad_input_type_candidate")) + out = [] + isolated_env("NIL_KILL_PRESSURE_SORT" => "calls") do + report.send(:append_pressure_list, out, pressure, "slots") + end + expect(out.join("\n")).to include("priority", "normal calls suggest") + end + + it "covers compact report helper decisions from parsed source and synthetic evidence" do + expect { capture_io { described_class.new(["--format", "xml"]) } }.to raise_error(SystemExit) + expect { capture_io { described_class.new(["--sarif"]) } }.to raise_error(SystemExit) + expect { capture_io { described_class.new(["--json"]) } }.to raise_error(SystemExit) + expect { capture_io { described_class.new(["--evidence"]) } }.to raise_error(SystemExit) + + Dir.mktmpdir("nil-kill-report-helpers", NilKill::ROOT) do |dir| + src = File.join(dir, "src") + FileUtils.mkdir_p(src) + source_path = File.join(src, "usage_probe.rb") + File.write(source_path, <<~RUBY) + class UsageProbe + def leaf; "x"; end + def voidish; puts "x"; end + def wrapper(flag) + if flag + return leaf + else + helper(leaf) + end + end + def helper(value); value; end + end + RUBY + rel = Pathname.new(source_path).relative_path_from(Pathname.new(NilKill::ROOT)).to_s + excluded = File.join(src, "excluded.rb") + File.write(excluded, "def excluded; leaf; end\n") + + leaf = { "path" => rel, "line" => 2, "end_line" => 2, "class" => "UsageProbe", "method" => "leaf", "kind" => "instance", "params" => [], "sig" => "sig { returns(String) }" } + voidish = { "path" => rel, "line" => 3, "end_line" => 3, "class" => "UsageProbe", "method" => "voidish", "kind" => "instance", "params" => [], "sig" => "sig { returns(T.untyped) }" } + wrapper = { "path" => rel, "line" => 4, "end_line" => 10, "class" => "UsageProbe", "method" => "wrapper", "kind" => "instance", "params" => [{ "name" => "flag", "type" => "T.untyped", "nil_default" => true }], "uses_yield" => false, "sig" => "sig { params(flag: T.untyped).returns(T.untyped) }" } + wrapper["return_origin"] = { "sources" => [{ "kind" => "unknown", "unknown_reasons" => ["struct/array/collection value items"], "code" => "items" }] } + helper = { "path" => rel, "line" => 11, "end_line" => 11, "class" => "UsageProbe", "method" => "helper", "kind" => "instance", "params" => [{ "name" => "value", "type" => "T.untyped" }], "sig" => "sig { params(value: T.untyped).void }" } + blocky = { "path" => rel, "line" => 12, "end_line" => 12, "class" => "UsageProbe", "method" => "with_block", "kind" => "instance", "params" => [{ "name" => "block", "type" => "T.untyped" }], "uses_yield" => true, "sig" => "sig { params(block: T.untyped).returns(T.untyped) }" } + weak_array = { "path" => rel, "line" => 13, "end_line" => 13, "class" => "UsageProbe", "method" => "items", "kind" => "instance", "params" => [{ "name" => "items", "type" => "T::Array[T.untyped]" }], "sig" => "sig { params(items: T::Array[T.untyped]).returns(T::Hash[T.untyped, T.untyped]) }" } + empty_array = { "path" => rel, "line" => 14, "end_line" => 14, "class" => "UsageProbe", "method" => "empty_items", "kind" => "instance", "params" => [{ "name" => "items", "type" => "T::Array[T.untyped]" }], "sig" => "sig { params(items: T::Array[T.untyped]).returns(String) }" } + + evidence = { + "target_dirs" => [src], + "target_exclude_dirs" => [excluded], + "methods" => [ + method_runtime_record(wrapper, calls: 3, params_by_name: { "flag" => [] }, returns: %w[TrueClass FalseClass]), + method_runtime_record(helper, calls: 2, params_by_name: { "value" => %w[String User] }), + method_runtime_record(weak_array, calls: 4, param_elem: { "items" => ["String"] }, return_kv: [%w[String], %w[Integer]], return_elem_shapes: [], return_kv_shapes: [[{ "kind" => "class", "name" => "String" }], [{ "kind" => "class", "name" => "Integer" }]]), + method_runtime_record(empty_array, calls: 2), + { + "source" => helper, + "calls" => 5, + "ok_calls" => 5, + "raised_calls" => 0, + "params_by_name" => { "value" => %w[String User] }, + "params_ok" => { "value" => %w[String User] }, + "params_raised" => {}, + "param_sites" => { "value" => { "#{source_path}:6:String" => 2 } }, + "param_sites_ok" => { "value" => { "#{source_path}:6:String" => 2 } }, + "param_traces" => { "value" => { "#{File.join(src, "caller.rb")}:3|#{source_path}:11:String" => 2 } }, + "param_traces_ok" => { "value" => { "#{File.join(src, "caller.rb")}:3|#{source_path}:11:String" => 2 } }, + "param_elem" => {}, + "param_kv" => {}, + "param_elem_shapes" => {}, + "param_kv_shapes" => {}, + "returns" => [], + "return_elem" => [], + "return_kv" => [[], []], + "return_elem_shapes" => [], + "return_kv_shapes" => [[], []], + "raised" => [], + }, + ], + "facts" => { + "existing_sigs" => [leaf, voidish, wrapper, helper, blocky, weak_array, empty_array], + "unsigned_methods" => [], + "tlet_sites" => [ + { "path" => rel, "line" => 30, "name" => "cache", "tlet" => true, "type" => "T::Array[T.untyped]" }, + ], + "return_origins" => [ + voidish.merge("candidate_type" => "T.noreturn", "return_syntax" => "implicit", "sources" => [{ "kind" => "call_untyped", "callee" => "raise", "line" => 3, "code" => "raise" }], "blockers" => ["untyped callee raise"]), + wrapper.merge("candidate_type" => "String", "return_syntax" => "explicit", "sources" => [{ "kind" => "static", "type" => "String", "line" => 6, "code" => '"x"' }], "blockers" => []), + helper.merge("candidate_type" => "T.untyped", "return_syntax" => "implicit", "sources" => [{ "kind" => "ivar_read", "line" => 11, "code" => "@value" }], "blockers" => []), + ], + "param_origins" => [ + { "path" => rel, "line" => 20, "callee" => "helper", "slot" => "value", "origin_kind" => "unknown", "unknown_reasons" => ["class variable @@state"], "code" => "helper(@@state)" }, + { "path" => rel, "line" => 21, "callee" => "helper", "slot" => "value", "origin_kind" => "typed_return", "source_method" => "leaf", "type" => "String", "code" => "helper(leaf)" }, + { "path" => rel, "line" => 22, "callee" => "items", "slot" => "items", "origin_kind" => "unknown", "unknown_reasons" => ["struct/array/collection value items"], "code" => "items" }, + ], + "struct_declarations" => [ + { "path" => rel, "line" => 40, "class" => "UsageRecord", "fields" => %w[name tags weak missing nilable], "field_types" => {} }, + ], + "struct_field_runtime" => [ + { "class" => "UsageRecord", "field" => "tags", "classes" => ["Array"], "elem_classes" => ["String"], "calls" => 2 }, + { "class" => "UsageRecord", "field" => "nilable", "classes" => %w[String NilClass], "calls" => 2 }, + ], + "struct_field_static" => [ + { "class" => "UsageRecord", "field" => "name", "type" => "String" }, + { "class" => "UsageRecord", "field" => "weak", "type" => "T::Array[T.untyped]" }, + { "class" => "UsageRecord", "field" => "missing", "type" => "" }, + ], + "collection_runtime" => [ + { "path" => rel, "line" => 13, "owner_kind" => "method_param", "name" => "items", "kind" => "array", "elem_classes" => ["String"], "classes" => ["Array"], "calls" => 4, "mutation_sites" => { "#{rel}:50" => 2 } }, + { "path" => rel, "line" => 13, "owner_kind" => "method_return", "name" => "items", "kind" => "hash", "key_classes" => ["String"], "value_classes" => ["Integer"], "classes" => ["Hash"], "calls" => 4, "mutation_sites" => {} }, + { "path" => rel, "line" => 14, "owner_kind" => "method_param", "name" => "items", "kind" => "array", "elem_classes" => [], "classes" => ["Array"], "calls" => 2, "mutation_sites" => { "#{rel}:51" => 1 } }, + { "path" => rel, "line" => 30, "owner_kind" => "tlet", "name" => "cache", "kind" => "array", "elem_classes" => [], "elem_shapes" => [{ "kind" => "class", "name" => "User" }], "classes" => ["Array"], "calls" => 1, "mutation_sites" => {} }, + { "path" => rel, "line" => 40, "owner_kind" => "struct_field", "name" => "UsageRecord.weak", "kind" => "array", "elem_classes" => %w[String Integer Symbol Float Object User Extra], "classes" => ["Array"], "calls" => 1, "mutation_sites" => {} }, + ], + "collection_index_lookups" => [ + { "path" => rel, "line" => 60, "code" => "record[:name]", "receiver" => "record", "index" => ":name", "lookup_type" => "T.untyped", "status" => "unknown receiver", "origin" => { "kind" => "method parameter", "path" => rel, "line" => 11, "name" => "record", "type" => "T::Hash[T.untyped, T.untyped]" } }, + { "path" => rel, "line" => 61, "code" => "@record['id']", "receiver" => "@record", "index" => "'id'", "lookup_type" => "String", "status" => "typed collection receiver", "receiver_type" => "T::Hash[String, String]", "origin" => { "kind" => "instance variable", "name" => "@record" } }, + { "path" => rel, "line" => 62, "code" => "dynamic[key]", "receiver" => "dynamic[key]", "index" => "key", "lookup_type" => "T.untyped", "status" => "unknown receiver", "origin" => { "kind" => "forwarded return", "callee" => "load_record", "path" => rel, "line" => 2 } }, + ], + "hash_shapes" => [ + { "path" => rel, "line" => 70, "code" => "{name: 'Ada', tags: [{id: 1}]}", "keys" => %w[name tags], "value_types" => %w[String Array], "value_hash_shapes" => {}, "value_array_element_shapes" => { "tags" => { "keys" => { "id" => ["Integer"] }, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false } }, "poisoned" => false }, + { "path" => rel, "line" => 71, "code" => "{name: 'Grace', meta: {active: true}}", "keys" => %w[name meta], "value_types" => %w[String Hash], "value_hash_shapes" => { "meta" => { "keys" => { "active" => ["TrueClass"] }, "value_hash_shapes" => {}, "value_array_element_shapes" => {}, "poisoned" => false } }, "value_array_element_shapes" => {}, "poisoned" => false }, + ], + "hash_record_blockers" => [ + { "path" => rel, "line" => 72, "origin" => { "kind" => "local hash shape", "shape" => { "keys" => %w[name tags] } }, "reason" => "dynamic key" }, + ], + "hash_record_member_calls" => [ + { "path" => rel, "line" => 73, "field" => "name", "member" => "full_type", "origin" => { "kind" => "hash literal", "path" => rel, "line" => 70, "code" => "{name: 'Ada', tags: [{id: 1}]}" } }, + ], + }, + "diagnostics" => { "nil_origins" => [], "sorbet_errors" => [], "sorbet_feedback" => [] }, + "actions" => [ + { "kind" => "fix_sig_return", "confidence" => "review", "path" => rel, "line" => 4, "data" => { "type" => "String", "source" => "static_return_origin" } }, + { "kind" => "fix_sig_return", "confidence" => "high", "path" => rel, "line" => 4, "data" => { "type" => "String", "source" => "static_return_origin" } }, + { "kind" => "fix_sig_return", "confidence" => "low", "path" => rel, "line" => 3, "data" => { "type" => "T.noreturn" } }, + { "kind" => "narrow_generic_param", "confidence" => "review", "path" => rel, "line" => 13, "data" => { "name" => "items", "type" => "T::Array[String]" } }, + { "kind" => "narrow_generic_return", "confidence" => "high", "path" => rel, "line" => 13, "data" => { "type" => "T::Hash[String, Integer]" } }, + ], + } + + report = report_for(evidence) + allow(report).to receive(:struct_rbi_types).and_return( + ["UsageRecord", "name"] => "String", + ["UsageRecord", "tags"] => "T.untyped", + ["UsageRecord", "weak"] => "T::Array[T.untyped]", + ["UsageRecord", "nilable"] => "T.nilable(String)" + ) + + evidence_path = File.join(dir, "evidence.json") + File.write(evidence_path, JSON.generate(evidence)) + expect(described_class.new(["--evidence=#{evidence_path}"]).send(:read_evidence)).to include("facts") + expect(described_class.new(["--sarif=#{File.join(dir, "report.sarif")}"]).send(:report_filename)).to eq("report.sarif") + expect(described_class.new(["--json", File.join(dir, "report.json")]).send(:sarif_format?)).to be(true) + + usage = report.send(:return_usage_by_name, evidence) + graph = report.send(:return_usage_graph_summary, evidence) + expect(usage["leaf"].values.sum + graph["used"].size).to be > 0 + expect(report.send(:evidence_target_files, evidence)).to include(source_path) + expect(report.send(:return_hygiene_rows, evidence).map { |row| row["fixability"] }).to include(a_string_matching(/review action|missing action|needs collection|addressed/)) + expect(report.send(:hygiene_fixability_rank, "needs collection/field evidence")).to eq(3) + expect(report.send(:return_fix_action_lookup, evidence)[[rel, 4]]["confidence"]).to eq("high") + + suggestions = %w[raise puts each any? join [] []= mystery].map do |callee| + report.send(:root_return_suggestion, "untyped callee #{callee}", { "callee" => callee }, {}) + end + expect(suggestions.compact.join("\n")).to include("void", "boolean", "String", "mutation") + expect(report.send(:root_return_suggestion, "setter assignment name=", { "callee" => "name=" }, {})).to include("assignment") + + foreign_lines = [] + report.send(:append_foreign_class_pressure, foreign_lines, evidence) + expect(foreign_lines.join("\n")).to include("Foreign Scalar", "UsageProbe#helper") + + unknown_lines = [] + report.send(:append_unknown_expression_breakdowns, unknown_lines, evidence["facts"]["existing_sigs"], evidence["facts"]["param_origins"]) + expect(unknown_lines.join("\n")).to include("class variable", "struct/array/collection") + expect(report.send(:unknown_reason_label, "global variable $state")).to include("global variable") + + expect(report.send(:weak_signature_type_reason, "T.any(String, Integer)")).to include("union") + expect(report.send(:weak_signature_type_reason, "T::Set[T.untyped]")).to include("collection") + expect(report.send(:weak_signature_type_reason, "T.nilable(T.untyped)")).to include("nested") + expect(report.send(:untyped_param_bucket, wrapper, "flag", [], { "calls" => 1 })).to include("defaultable") + expect(report.send(:untyped_param_bucket, blocky, "block", [], { "calls" => 1 })).to include("block-like") + expect(report.send(:untyped_return_bucket, wrapper, { "calls" => 1, "returns" => %w[TrueClass FalseClass] }, Set.new)).to include("Boolean") + + collection_slots = report.send(:collection_signature_slots, evidence) + collection_lines = [] + report.send(:append_collection_slot_coverage, collection_lines, collection_slots) + report.send(:append_collection_slot_candidates, collection_lines, evidence, collection_slots) + report.send(:append_collection_blocker_pressure, collection_lines, evidence, collection_slots) + report.send(:append_runtime_collection_observations, collection_lines, evidence.dig("facts", "collection_runtime")) + report.send(:append_collection_index_lookup_report, collection_lines, evidence.dig("facts", "collection_index_lookups")) + expect(collection_lines.join("\n")).to include("Weak Collection Slots", "mutation sites", "Runtime Collection", "forwarded return") + expect(report.send(:collection_slot_missing_candidate_reason, collection_slots.find { |slot| slot.dig("method", "method") == "empty_items" })).to include("no element") + expect(report.send(:collection_origin_label, { "kind" => "array literal", "name" => "records", "path" => rel, "line" => 1 })).to include("array literal") + expect(report.send(:collection_origin_label, { "kind" => "instance variable", "name" => "@record" })).to include("instance") + + struct_lines = [] + report.send(:append_struct_field_coverage, struct_lines, evidence.dig("facts", "struct_declarations"), accumulator: described_class::HygieneCountsAcc.new) + report.send(:append_struct_field_breakdown, struct_lines, evidence.dig("facts", "struct_declarations"), evidence.dig("facts", "struct_field_runtime"), evidence.dig("facts", "struct_field_static")) + report.send(:append_struct_field_candidates, struct_lines, evidence.dig("facts", "struct_field_runtime"), evidence.dig("facts", "struct_field_static")) + expect(struct_lines.join("\n")).to include("missing field type", "untyped with runtime candidate", "typed but nilable") + + hash_lines = [] + report.send(:append_hash_shape_candidates, hash_lines, evidence.dig("facts", "hash_shapes")) + report.send(:append_hash_record_struct_candidates, hash_lines, evidence) + report.send(:append_hash_record_struct_pressure, hash_lines, evidence) + expect(hash_lines.join("\n")).to include("Hash Shapes", "High-Pressure HashMaps") + candidates = report.send(:hash_record_struct_candidates, evidence) + expect(report.send(:candidate_matches_pressure?, candidates, report.send(:hash_record_struct_pressure, evidence).first)).to be(true) + expect(report.send(:hash_record_lookup?, evidence.dig("facts", "collection_index_lookups").last)).to be(false) + expect(report.send(:hash_record_pressure_label, evidence.dig("facts", "collection_index_lookups").first)).to include("method parameter") + expect(report.send(:hash_record_nested_structs, candidates.first["fields"])).not_to be_nil + expect(report.send(:hash_record_protocol_type_for_members, %w[full_type token])).to eq("AST::Locatable") + expect(report.send(:flow_graph, evidence)).to be_a(NilKill::FlowGraph) + + acc = described_class::HygieneCountsAcc.new + acc.add("param", "weak_collection" => 2) + primitive_lines = [] + report.send(:append_primitive_collection_summary, primitive_lines, acc) + expect(primitive_lines.join("\n")).to include("Primitive Collection Slots") + + expect(report.send(:strip_nilable, "T.nilable(String)")).to eq("String") + end + end + end +end diff --git a/gems/nil-kill/spec/hidden_enum_pressure_spec.rb b/gems/nil-kill/spec/hidden_enum_pressure_spec.rb index ba6f828c4..da23b7409 100644 --- a/gems/nil-kill/spec/hidden_enum_pressure_spec.rb +++ b/gems/nil-kill/spec/hidden_enum_pressure_spec.rb @@ -143,6 +143,126 @@ def apply(status) end end + it "loads persisted hidden enum observations without rescanning source" do + observations = [ + { + "key" => "slot-1", + "kind" => "param", + "path" => "src/workflow.rb", + "line" => "12", + "owner" => "Workflow", + "method" => "transition", + "method_kind" => "instance", + "slot" => "state", + "type" => "T.untyped", + "event" => "decision", + "values" => [{ "kind" => "Symbol", "value" => ":draft" }, { "kind" => "Symbol", "value" => ":sent" }], + "site" => { "path" => "src/workflow.rb", "line" => 13, "kind" => "case" }, + }, + { + "key" => "slot-1", + "kind" => "param", + "path" => "src/workflow.rb", + "line" => "12", + "owner" => "Workflow", + "method" => "transition", + "method_kind" => "instance", + "slot" => "state", + "type" => "T.untyped", + "event" => "producer", + "values" => [{ "kind" => "Symbol", "value" => ":archived" }], + "site" => { "path" => "src/workflow.rb", "line" => 10, "kind" => "producer" }, + }, + { + "key" => "slot-1", + "kind" => "param", + "path" => "src/workflow.rb", + "line" => "12", + "owner" => "Workflow", + "method" => "transition", + "method_kind" => "instance", + "slot" => "state", + "type" => "T.untyped", + "event" => "blocker", + "site" => { "path" => "src/workflow.rb", "line" => 9, "kind" => "open-world producer" }, + }, + ] + evidence = { + "facts" => { "hidden_enum_observations" => observations }, + "methods" => [{ + "calls" => 5, + "params_by_name" => { "state" => ["Symbol"] }, + "source" => { + "path" => "src/workflow.rb", + "line" => 12, + "class" => "Workflow", + "method" => "transition", + "kind" => "instance", + }, + }], + } + + row = described_class.scan(["/definitely/missing.rb"], evidence: evidence).fetch(0) + + expect(row).to include( + "values" => %w[:archived :draft :sent], + "primitive_kinds" => ["Symbol"], + "confidence" => "review", + "decision_pressure" => 2 + ) + expect(row.fetch("runtime")).to include("calls" => 5, "classes" => ["Symbol"]) + expect(row.fetch("blockers")).to include(a_hash_including("kind" => "open-world producer")) + end + + it "tracks aliases, defaults, class variables, literal-left comparisons, and dynamic blockers" do + Dir.mktmpdir("nil-kill-hidden-enum-advanced", NilKill::ROOT) do |dir| + path = write_sample(dir, "advanced.rb", <<~RUBY) + class Advanced + extend T::Sig + + sig do + params(mode: T.any(String, Symbol), state: T.untyped, source: String).void + end + def call(mode = :draft, state:, source: "manual") + alias_mode = T.must(mode) + return if "manual" == alias_mode + + [:draft, :queued].member?(alias_mode) + @state = "\#{source}-suffix" + @@global_state = File.read("state.txt") + + case @state + when "ready" + true + when "done" + false + end + + case @@global_state + when :open + true + when :closed + false + end + end + end + RUBY + + rows = described_class.scan([path]) + mode = rows.find { |row| row["slot"] == "mode" } + state = rows.find { |row| row["slot"] == "@state" } + global = rows.find { |row| row["slot"] == "@@global_state" } + + expect(mode).to include( + "values" => ['"manual"', ":draft", ":queued"], + "primitive_kinds" => %w[String Symbol], + "confidence" => "high" + ) + expect(state.fetch("blockers")).to include(a_hash_including("kind" => "dynamic primitive producer")) + expect(global.fetch("blockers")).to include(a_hash_including("kind" => "open-world producer")) + end + end + it "does not report a single literal check as an enum candidate" do Dir.mktmpdir("nil-kill-hidden-enum", NilKill::ROOT) do |dir| path = write_sample(dir, "single.rb", <<~RUBY) diff --git a/gems/nil-kill/spec/support/mini_collect.rb b/gems/nil-kill/spec/support/mini_collect.rb index be4d8f46b..60d22be71 100644 --- a/gems/nil-kill/spec/support/mini_collect.rb +++ b/gems/nil-kill/spec/support/mini_collect.rb @@ -14,6 +14,7 @@ module MiniCollect TRACER = File.join(NilKill::ROOT, "gems", "nil-kill", "lib", "nil_kill", "runtime_trace.rb") + SUBPROCESS_COVERAGE = File.join(__dir__, "subprocess_coverage.rb") def in_tmp(&blk) Dir.mktmpdir("nk-cap", NilKill::ROOT, &blk) @@ -54,7 +55,9 @@ def mini_collect(dir, lib_rel, driver_src, extra_files: {}, instrument: true) "NIL_KILL_TRACE_METHODS" => "0", "NIL_KILL_TMP_DIR" => NilKill::TMP_DIR, "NIL_KILL_TARGETS" => dir, - "RUBYOPT" => "-r#{TRACER}", + "RUBYOPT" => ENV["NIL_KILL_SUBPROCESS_COVERAGE"] == "1" ? "-r#{SUBPROCESS_COVERAGE} -r#{TRACER}" : "-r#{TRACER}", + "NIL_KILL_SUBPROCESS_COVERAGE_CHILD" => ENV["NIL_KILL_SUBPROCESS_COVERAGE"] == "1" ? "1" : nil, + "NIL_KILL_SHARED_COVERAGE" => ENV["NIL_KILL_SUBPROCESS_COVERAGE"] == "1" ? "1" : nil, } out, err, status = Open3.capture3(env, "bundle", "exec", "ruby", driver, chdir: NilKill::ROOT) rd = NilKill::RUNTIME_DIR diff --git a/gems/nil-kill/spec/support/subprocess_coverage.rb b/gems/nil-kill/spec/support/subprocess_coverage.rb new file mode 100644 index 000000000..042c6e29d --- /dev/null +++ b/gems/nil-kill/spec/support/subprocess_coverage.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +return unless ENV["NIL_KILL_SUBPROCESS_COVERAGE_CHILD"] == "1" + +begin + require "simplecov" +rescue LoadError + return +end + +root = File.expand_path("../../../..", __dir__) +SimpleCov.root(root) +SimpleCov.command_name "nil-kill-subprocess-#{Process.pid}" +SimpleCov.coverage_dir ENV.fetch("NIL_KILL_SUBPROCESS_COVERAGE_DIR", File.join(root, "coverage", "ruby-gems")) +SimpleCov.print_error_status = false +SimpleCov.minimum_coverage 0 +SimpleCov.formatter SimpleCov::Formatter::SimpleFormatter +SimpleCov.start do + enable_coverage :branch + add_filter "/gems/nil-kill/spec/" + add_filter "/tmp/" + add_filter "/vendor/" +end diff --git a/gems/nil-kill/src/actions.rs b/gems/nil-kill/src/actions.rs index 8fa430f4c..4ba4eef7f 100644 --- a/gems/nil-kill/src/actions.rs +++ b/gems/nil-kill/src/actions.rs @@ -2007,6 +2007,10 @@ mod tests { use std::fs; use std::path::PathBuf; + fn input_from_json(value: serde_json::Value) -> crate::schemas::InputState { + serde_json::from_value(value).unwrap() + } + #[test] fn test_actions_oracle_fixtures() { let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -2537,4 +2541,499 @@ mod tests { let res2 = super::shape_union_type(&arr_shapes.as_array().unwrap()); assert_eq!(res2, Some("T::Array[Float]".to_string())); } + + #[test] + fn test_type_helper_conservative_edges() { + use serde_json::json; + + assert_eq!( + super::sorbet_type(&["TrueClass".to_string(), "FalseClass".to_string()], true), + "T::Boolean" + ); + assert_eq!( + super::conservative_element_type(&["NilClass".to_string(), "String".to_string()]), + Some("T.nilable(String)".to_string()) + ); + assert_eq!( + super::conservative_element_type(&["TrueClass".to_string(), "FalseClass".to_string()]), + Some("T::Boolean".to_string()) + ); + assert_eq!( + super::conservative_element_type(&["AST::Send".to_string()]), + None + ); + assert_eq!( + super::conservative_element_type(&["String".to_string(), "Integer".to_string()]), + None + ); + + let mixed_hash = json!([ + { + "kind": "hash", + "keys": [{ "kind": "class", "name": "String" }], + "values": [ + { "kind": "class", "name": "String" }, + { "kind": "class", "name": "Integer" } + ] + } + ]); + assert_eq!( + super::shape_union_type(mixed_hash.as_array().unwrap()), + None + ); + assert_eq!( + super::shape_union_type(&[json!({ "kind": "unknown" })]), + None + ); + assert_eq!(super::shape_union_type(&[json!({})]), None); + + let array_classes = json!(["Symbol"]); + assert_eq!( + super::generic_candidate_type("Array", Some(&array_classes), None, None, None), + Some("T::Array[Symbol]".to_string()) + ); + let set_classes = json!(["String"]); + assert_eq!( + super::generic_candidate_type("Set", Some(&set_classes), None, None, None), + Some("T::Set[String]".to_string()) + ); + let hash_classes = json!([["Symbol"], ["Float"]]); + assert_eq!( + super::generic_candidate_type("Hash", None, Some(&hash_classes), None, None), + Some("T::Hash[Symbol, Float]".to_string()) + ); + assert_eq!( + super::preserve_nilable_wrapper("T.nilable(T.untyped)", "String"), + "T.nilable(String)" + ); + assert_eq!( + super::extract_return_type("sig { returns(T.nilable(String)) }"), + Some("T.nilable(String)".to_string()) + ); + assert_eq!( + super::collection_narrowing_confidence(1, "T::Array[User]"), + "review" + ); + } + + #[test] + fn test_dead_checks_deterministic_guards_and_missing_sig_confidence() { + use serde_json::json; + + let input = input_from_json(json!({ + "methods": [ + { "has_sig": false, "source": null }, + { + "has_sig": false, + "calls": 25, + "params_by_name": { "name": ["String"] }, + "returns": ["Integer"], + "source": { + "path": "src/signup.rb", + "line": 10, + "class": "Signup", + "method": "create", + "kind": "instance", + "params": [{ "name": "name", "nil_default": true, "type": null }], + "uses_yield": false + } + }, + { + "has_sig": false, + "calls": 25, + "params_by_name": { "block_arg": ["String"] }, + "returns": ["String"], + "source": { + "path": "src/signup.rb", + "line": 20, + "class": "Signup", + "method": "around", + "kind": "instance", + "params": [{ "name": "block_arg", "nil_default": false, "type": null }], + "uses_yield": true + } + }, + { + "has_sig": true, + "source": { + "path": "src/signup.rb", + "line": 30, + "class": "Signup", + "method": "already_typed", + "kind": "instance", + "sig": "sig { void }" + } + } + ], + "facts": { + "dead_nil_checks": [ + { "kind": "nil_check", "path": "src/signup.rb", "line": 3, "code": "user.nil?", "reason": "user is non-nil" }, + { "kind": "safe_nav", "path": "src/signup.rb", "line": 4, "code": "user&.name", "reason": "receiver is non-nil" } + ], + "deterministic_guards": [ + { "proof_tier": "runtime_observed", "predicate_kind": "type_check", "path": "src/signup.rb", "line": 5, "code": "name.is_a?(String)", "truth_value": true, "reason": "runtime only" }, + { "proof_tier": "static_proven", "predicate_kind": "nil_check", "path": "src/signup.rb", "line": 6, "code": "name.nil?", "truth_value": false, "reason": "handled elsewhere" }, + { "proof_tier": "static_proven", "predicate_kind": "type_check", "path": "src/signup.rb", "line": 7, "code": "name.is_a?(String)", "truth_value": true, "reason": "signature proves String" } + ] + } + })); + + let actions = super::build_actions(&input); + assert!(actions.iter().any(|a| a.kind == "replace_dead_nil_check")); + assert!(actions.iter().any(|a| a.kind == "remove_dead_safe_nav")); + let guard = actions + .iter() + .find(|a| a.kind == "replace_deterministic_guard") + .unwrap(); + assert_eq!(guard.line, 7); + assert!(guard.message.contains("always true")); + + let create_sig = actions + .iter() + .find(|a| a.kind == "add_sig" && a.line == 10) + .unwrap(); + assert_eq!(create_sig.confidence, "high"); + assert_eq!( + create_sig.data["sig"], + "sig { params(name: T.nilable(String)).returns(Integer) }" + ); + + let around_sig = actions + .iter() + .find(|a| a.kind == "add_sig" && a.line == 20) + .unwrap(); + assert_eq!(around_sig.confidence, "review"); + assert!(around_sig.message.contains("block typing needs review")); + } + + #[test] + fn test_signature_validation_handles_collections_voids_and_static_return_origins() { + use serde_json::json; + + let unused_key = + serde_json::json!(["src/service.rb", 40, "Service", "unused", "instance"]).to_string(); + let contradicted_key = serde_json::json!([ + "src/service.rb", + 50, + "Service", + "contradicted_unused", + "instance" + ]) + .to_string(); + + let input = input_from_json(json!({ + "methods": [ + { + "has_sig": true, + "calls": 25, + "params_ok": { "value": ["String"], "items": ["Array"] }, + "param_elem": { "items": ["Integer"] }, + "returns": ["Hash"], + "return_kv": [["String"], ["Integer"]], + "source": { + "path": "src/service.rb", + "line": 10, + "class": "Service", + "method": "process", + "kind": "instance", + "sig": "sig { params(value:T.untyped, items: T::Array[T.untyped]).returns(T.untyped) }", + "params": [ + { "name": "value", "type": "T.untyped" }, + { "name": "items", "type": "T::Array[T.untyped]" } + ] + } + }, + { + "has_sig": true, + "calls": 1, + "returns": [], + "source": { + "path": "src/service.rb", + "line": 30, + "class": "Service", + "method": "always_raises", + "kind": "instance", + "sig": "sig { returns(T.untyped) }", + "params": [], + "noreturn_candidate": true + } + }, + { + "has_sig": true, + "calls": 5, + "returns": [], + "source": { + "path": "src/service.rb", + "line": 40, + "class": "Service", + "method": "unused", + "kind": "instance", + "sig": "sig { returns(T.untyped) }", + "params": [] + } + }, + { + "has_sig": true, + "calls": 5, + "returns": ["String"], + "source": { + "path": "src/service.rb", + "line": 50, + "class": "Service", + "method": "contradicted_unused", + "kind": "instance", + "sig": "sig { returns(T.untyped) }", + "params": [] + } + }, + { + "has_sig": true, + "calls": 5, + "returns": [], + "source": { + "path": "src/service.rb", + "line": 60, + "class": "Service", + "method": "literal_return", + "kind": "instance", + "sig": "sig { returns(T.untyped) }", + "params": [] + } + }, + { + "has_sig": true, + "calls": 5, + "returns": [], + "source": { + "path": "src/service.rb", + "line": 70, + "class": "Service", + "method": "bare_static_return", + "kind": "instance", + "sig": "sig { returns(T.untyped) }", + "params": [] + } + }, + { + "has_sig": true, + "calls": 5, + "returns": ["String"], + "source": { + "path": "src/service.rb", + "line": 80, + "class": "Service", + "method": "void_contradiction", + "kind": "instance", + "sig": "sig { returns(T.untyped) }", + "params": [] + } + } + ], + "unused_return_methods_by_location": { + (unused_key): true, + (contradicted_key): true + }, + "facts": { + "return_origins": [ + { + "path": "src/service.rb", + "line": 60, + "class": "Service", + "method": "literal_return", + "kind": "instance", + "confidence": "strong", + "candidate_type": "String", + "sources": [{ "kind": "static", "type": "String", "code": "\"ok\"" }], + "blockers": [] + }, + { + "path": "src/service.rb", + "line": 70, + "class": "Service", + "method": "bare_static_return", + "kind": "instance", + "confidence": "strong", + "candidate_type": "String", + "sources": [{ "kind": "static", "type": "String", "code": "value" }], + "blockers": [] + }, + { + "path": "src/service.rb", + "line": 80, + "class": "Service", + "method": "void_contradiction", + "kind": "instance", + "confidence": "strong", + "candidate_type": "void", + "sources": [{ "kind": "static", "type": "void", "code": "nil" }], + "blockers": [] + } + ] + } + })); + + let actions = super::build_actions(&input); + assert!(actions.iter().any(|a| { + a.kind == "narrow_generic_param" + && a.line == 10 + && a.data["type"] == "T::Array[Integer]" + && a.confidence == "high" + })); + assert!(actions + .iter() + .any(|a| { a.kind == "fix_sig_param" && a.line == 10 && a.data["name"] == "value" })); + assert!(actions.iter().any(|a| { + a.kind == "fix_sig_return" + && a.line == 10 + && a.data["type"] == "T::Hash[String, Integer]" + })); + assert!(actions.iter().any(|a| { + a.kind == "fix_sig_return" && a.line == 30 && a.data["type"] == "T.noreturn" + })); + assert!(actions + .iter() + .any(|a| { a.kind == "fix_sig_return" && a.line == 40 && a.data["type"] == "void" })); + assert!(actions + .iter() + .any(|a| { a.kind == "fix_sig_return" && a.line == 50 && a.data["type"] == "String" })); + assert!(!actions.iter().any(|a| { + a.kind == "fix_sig_return" + && a.line == 50 + && a.data.get("source").and_then(|v| v.as_str()) == Some("unused_return") + })); + + let literal = actions + .iter() + .find(|a| a.kind == "fix_sig_return" && a.line == 60) + .unwrap(); + assert_eq!(literal.confidence, "high"); + + let bare = actions + .iter() + .find(|a| a.kind == "fix_sig_return" && a.line == 70) + .unwrap(); + assert_eq!(bare.confidence, "review"); + assert!(actions + .iter() + .any(|a| { a.kind == "fix_sig_return" && a.line == 80 && a.data["type"] == "String" })); + assert!(!actions.iter().any(|a| { + a.kind == "fix_sig_return" + && a.line == 80 + && a.data.get("source").and_then(|v| v.as_str()) == Some("static_return_origin") + && a.data.get("type").and_then(|v| v.as_str()) == Some("void") + })); + } + + #[test] + fn test_static_backflow_forwarded_returns_and_struct_field_rbi_paths() { + use serde_json::json; + + let input = input_from_json(json!({ + "methods": [], + "facts": { + "existing_sigs": [ + { + "path": "src/service.rb", + "line": 10, + "class": "Service", + "method": "accept", + "kind": "instance", + "sig": "sig { params(user: T.untyped, ignored: T.untyped).void }" + }, + { + "path": "src/service.rb", + "line": 20, + "class": "Service", + "method": "forwarded", + "kind": "instance", + "sig": "sig { returns(T.untyped) }" + }, + { + "path": "src/service.rb", + "line": 30, + "class": "Service", + "method": "not_forwarded", + "kind": "instance", + "sig": "sig { returns(T.untyped) }", + "return_origin": { "sources": [{ "kind": "static", "type": "String", "code": "\"ok\"" }] } + }, + { + "path": "src/service.rb", + "line": 40, + "class": "Service", + "method": "bad_forward", + "kind": "instance", + "sig": "sig { returns(T.untyped) }", + "return_origin": { "sources": [{ "kind": "typed_call", "type": "Object", "callee": "load_any", "line": 41 }] } + } + ], + "param_origins": [ + { "callee": "accept", "slot": "user", "origin_kind": "static", "type": "User", "path": "src/caller.rb", "line": 3, "code": "accept(user)" }, + { "callee": "accept", "slot": "user", "origin_kind": "static", "type": "User", "path": "src/caller.rb", "line": 3, "code": "accept(user)" }, + { "callee": "accept", "slot": "ignored", "origin_kind": "unknown", "path": "src/caller.rb", "line": 4, "code": "accept(dynamic)" } + ], + "return_origins": [ + { + "path": "src/service.rb", + "line": 20, + "class": "Service", + "method": "forwarded", + "kind": "instance", + "sources": [ + { "kind": "typed_call", "type": "String", "callee": "load_name", "line": 21 }, + { "kind": "nil" } + ] + } + ], + "struct_field_runtime": [ + { "class": "User", "field": "name", "classes": ["String"], "elem_classes": [], "calls": 5 }, + { "class": "User", "field": "empty", "classes": [], "elem_classes": [], "calls": 5 } + ], + "struct_field_static": [ + { "class": "User", "field": "missing_runtime", "type": "" } + ] + } + })); + + let actions = super::build_actions(&input); + let backflow = actions + .iter() + .find(|a| a.kind == "fix_sig_param" && a.line == 10) + .unwrap(); + assert_eq!(backflow.data["name"], "user"); + assert_eq!(backflow.data["type"], "User"); + assert_eq!(backflow.data["callsite_count"], 2); + assert_eq!( + backflow + .data + .get("callsites") + .and_then(|v| v.as_object()) + .and_then(|m| m.get("src/caller.rb:3:accept(user)")) + .and_then(|v| v.as_i64()), + Some(2) + ); + + let forwarded = actions + .iter() + .find(|a| a.kind == "fix_sig_return" && a.line == 20) + .unwrap(); + assert_eq!(forwarded.confidence, "review"); + assert_eq!(forwarded.data["type"], "T.any(NilClass, String)"); + assert!(!actions + .iter() + .any(|a| a.kind == "fix_sig_return" && a.line == 30)); + assert!(!actions + .iter() + .any(|a| a.kind == "fix_sig_return" && a.line == 40)); + + let rbi = actions + .iter() + .find(|a| a.kind == "add_struct_field_sig" && a.data["target"] == "rbi") + .unwrap(); + assert_eq!(rbi.path, "sorbet/rbi/ast-struct-fields.rbi"); + assert_eq!(rbi.data["field"], "name"); + assert!(!actions + .iter() + .any(|a| a.kind == "add_struct_field_sig" && a.data["field"] == "empty")); + } } diff --git a/gems/nil-kill/src/main.rs b/gems/nil-kill/src/main.rs index 2ac46a07b..0e8242016 100644 --- a/gems/nil-kill/src/main.rs +++ b/gems/nil-kill/src/main.rs @@ -20,8 +20,8 @@ fn main() -> Result<()> { let input_data = fs::read_to_string(input_path) .with_context(|| format!("Failed to read input file: {}", input_path))?; - let input_state: InputState = serde_json::from_str(&input_data) - .with_context(|| "Failed to parse input JSON")?; + let input_state: InputState = + serde_json::from_str(&input_data).with_context(|| "Failed to parse input JSON")?; let output_state = OutputState { actions: build_actions(&input_state), diff --git a/gems/nil-kill/src/schemas.rs b/gems/nil-kill/src/schemas.rs index d50be8d15..e3286b409 100644 --- a/gems/nil-kill/src/schemas.rs +++ b/gems/nil-kill/src/schemas.rs @@ -14,7 +14,10 @@ where D: serde::Deserializer<'de>, { let values = Vec::>::deserialize(deserializer)?; - Ok(values.into_iter().map(|value| value.unwrap_or_default()).collect()) + Ok(values + .into_iter() + .map(|value| value.unwrap_or_default()) + .collect()) } #[derive(Debug, Default, Serialize, Deserialize)] diff --git a/gems/nil-kill/tests/cli.rs b/gems/nil-kill/tests/cli.rs new file mode 100644 index 000000000..877152686 --- /dev/null +++ b/gems/nil-kill/tests/cli.rs @@ -0,0 +1,51 @@ +use serde_json::json; +use std::fs; +use std::process::Command; + +#[test] +fn binary_reports_usage_and_round_trips_actions() { + let bin = env!("CARGO_BIN_EXE_nil-kill-infer-rust"); + + let usage = Command::new(bin).output().unwrap(); + assert!(!usage.status.success()); + assert!(String::from_utf8_lossy(&usage.stderr).contains("Usage: nil-kill-infer-rust")); + + let dir = tempfile::tempdir().unwrap(); + let input_path = dir.path().join("input.json"); + let output_path = dir.path().join("output.json"); + fs::write( + &input_path, + serde_json::to_vec_pretty(&json!({ + "methods": [], + "facts": { + "dead_nil_checks": [ + { + "kind": "nil_check", + "path": "src/user.rb", + "line": 7, + "code": "user.nil?", + "reason": "runtime evidence proves user is non-nil" + } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + let run = Command::new(bin) + .arg(&input_path) + .arg(&output_path) + .output() + .unwrap(); + assert!( + run.status.success(), + "stderr: {}", + String::from_utf8_lossy(&run.stderr) + ); + + let output: serde_json::Value = + serde_json::from_slice(&fs::read(&output_path).unwrap()).unwrap(); + assert_eq!(output["actions"][0]["kind"], "replace_dead_nil_check"); + assert_eq!(output["diagnostics"].as_object().unwrap().len(), 0); +} diff --git a/gems/slopcop/test/hardening_test.rb b/gems/slopcop/test/hardening_test.rb new file mode 100644 index 000000000..367d0a60f --- /dev/null +++ b/gems/slopcop/test/hardening_test.rb @@ -0,0 +1,515 @@ +# frozen_string_literal: true + +require "json" +require "minitest/autorun" +require "open3" +require "tmpdir" +require "fileutils" +require_relative "../lib/slopcop" + +class SlopCopHardeningTest < Minitest::Test + Arm = Struct.new(:file, :kind, :member, :decision_line, :decision_span, :line, :span, :body, keyword_init: true) + + def test_bugspots_log_scoring_and_blast_radius + log = <<~LOG + @@@100\tFix login + app/a.rb + app/b.rb + + @@@200\tDocs only + README.md + + @@@300\tbug fix payment + app/a.rb + app/a.rb + app/c.rb + LOG + events = SlopCop::Bugspots.parse_log(log) + assert_equal 2, events.size + assert_equal ["app/a.rb", "app/b.rb"], events.first.files + assert SlopCop::Bugspots.fix?("Closes payment bug", SlopCop::Bugspots::FIX_RE) + refute SlopCop::Bugspots.fix?("Refactor payment", SlopCop::Bugspots::FIX_RE) + + scores = SlopCop::Bugspots.score(events) + assert_operator scores.fetch("app/a.rb"), :>, scores.fetch("app/b.rb") + assert_equal({}, SlopCop::Bugspots.score([])) + + single = SlopCop::Bugspots.score([SlopCop::Bugspots::Event.new(time: 10, subject: "fix", files: ["only.rb"])]) + assert_in_delta 0.5, single.fetch("only.rb"), 0.0001 + + blast = SlopCop::Bugspots.blast_radius(events) + assert_equal "app/a.rb", blast.first.file + assert_equal 2, blast.first.fixes + assert_operator blast.first.max_touched, :>=, 2 + assert blast.first.partners.any? { |path, _| path == "app/c.rb" } + assert_equal [], SlopCop::Bugspots.blast_radius([]) + end + + def test_coverage_data_loads_merges_and_labels_boobytrap_json + Dir.mktmpdir do |dir| + file = File.join(dir, "src/app.rb") + FileUtils.mkdir_p(File.dirname(file)) + File.write(file, "def call\nend\n") + coverage = File.join(dir, "coverage.json") + File.write(coverage, JSON.dump( + "coverage" => { + "files" => { + file => { + "lines" => [1, nil, 0], + "branches" => { "[:if,0,1,1,1,4]" => { "[:then,1,1,1,1,4]" => 0 } }, + "branch_arms" => [ + { + "branch_id" => "b1", + "arm_id" => "a1", + "kind" => "if", + "member" => "then", + "decision_span" => [1, 1, 1, 4], + "arm_span" => [1, 1, 1, 4], + "hits" => 0 + } + ], + "source_path" => "src/app.rb", + "language" => "ruby", + "format" => "nil_kill_branch" + } + } + } + )) + + dataset = SlopCop::CoverageData.load(coverage, root: dir) + assert_equal ["Nil-Kill branch coverage"], [dataset.label] + assert_equal [:nil_kill_branch], dataset.formats + assert_equal ["src/app.rb"], dataset.covered_files(root: dir) + file_cov = dataset[file] + assert file_cov.line_coverage? + assert file_cov.branch_coverage? + assert file_cov.branch_arm_coverage? + assert file_cov.line_known?(1) + assert_nil file_cov.line_hits(0) + assert_equal 1, file_cov.line_hits(1) + + second = SlopCop::CoverageData::Dataset.new( + path: "second", + files: { + File.expand_path(file) => SlopCop::CoverageData::FileCoverage.new( + file: File.expand_path(file), + lines: [2, 3, nil], + branches: { "[:if,0,1,1,1,4]" => { "[:then,1,1,1,1,4]" => 2 } }, + branch_arms: [ + SlopCop::CoverageData::NativeBranchArm.new( + branch_id: "b1", + arm_id: "a1", + kind: "if", + member: "then", + decision_span: [1, 1, 1, 4], + arm_span: [1, 1, 1, 4], + hits: 2 + ) + ], + format: :simplecov, + source_path: "", + language: "" + ) + } + ) + merged = SlopCop::CoverageData.merge_datasets("merged", [dataset, second]) + merged_cov = merged[file] + assert_equal :multi, merged_cov.format + assert_equal [3, 3, 0], merged_cov.lines + assert_equal 2, merged_cov.branches.fetch("[:if,0,1,1,1,4]").fetch("[:then,1,1,1,1,4]") + assert_equal 2, merged_cov.branch_arms.first.hits + + assert_equal [], SlopCop::CoverageData.coverage_paths(nil) + assert_equal [coverage], SlopCop::CoverageData.coverage_paths("#{coverage}#{File::PATH_SEPARATOR}#{coverage}") + assert_equal "kcov Cobertura", SlopCop::CoverageData.format_label(:kcov_cobertura) + assert_equal :native_branch, SlopCop::CoverageData.branch_source(:nil_kill_branch) + + second_path = File.join(dir, "coverage2.json") + File.write(second_path, JSON.dump( + "files" => { + file => { + "lines" => [0, 1], + "branches" => {}, + "branch_arms" => [ + { + "branch_id" => "b2", + "arm_id" => "a2", + "kind" => "if", + "member" => "else", + "decision_span" => [1, 1, 1, 4], + "arm_span" => [2, 1, 2, 4], + "hits" => 1 + } + ], + "source_path" => "", + "language" => "", + "format" => "simplecov" + } + } + )) + loaded_multi = SlopCop::CoverageData.load([coverage, second_path].join(File::PATH_SEPARATOR), root: dir) + assert_equal :multi, loaded_multi[file].format + assert_equal 2, loaded_multi[file].branch_arms.size + end + end + + def test_coverage_data_resolves_directories_and_branch_arm_coverage_modes + Dir.mktmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, "coverage")) + resultset = File.join(dir, "coverage/.resultset.json") + File.write(resultset, "{}") + assert_equal resultset, SlopCop::CoverageData.resolve(dir) + glob_root = File.join(dir, "glob") + FileUtils.mkdir_p(File.join(glob_root, "nested/kcov-merged")) + glob_cov = File.join(glob_root, "nested/kcov-merged/cobertura.xml") + File.write(glob_cov, "") + assert_equal glob_cov, SlopCop::CoverageData.resolve(glob_root) + assert_nil SlopCop::CoverageData.resolve(File.join(dir, "missing")) + + arms = [ + Arm.new(file: "a.rb", kind: "if", member: "then", decision_line: 10, decision_span: [10, 1, 12, 3], line: 11, span: [11, 1, 11, 5]), + Arm.new(file: "a.rb", kind: "if", member: "else", decision_line: 10, decision_span: [10, 1, 12, 3], line: 12, span: [12, 1, 12, 5]), + Arm.new(file: "a.rb", kind: "case", member: "when", decision_line: 20, decision_span: [20, 1, 22, 3], line: 21, span: [21, 1, 21, 5]) + ] + + line_cov = SlopCop::CoverageData::FileCoverage.new( + file: File.join(dir, "a.rb"), + lines: Array.new(25).tap { |lines| lines[10] = 0; lines[11] = 2 }, + branches: {}, + branch_arms: [], + format: :kcov_cobertura, + source_path: "a.rb", + language: "ruby" + ) + line_results = SlopCop::CoverageData.branch_arm_coverage(line_cov, arms) + refute line_results[0].covered + assert line_results[1].covered + assert_equal :tree_sitter_static, line_results[0].source + + tuple_cov = SlopCop::CoverageData::FileCoverage.new( + file: File.join(dir, "a.rb"), + lines: [], + branches: { + "[:if,0,10,1,12,3]" => { + "[:then,1,11,1,11,5]" => 0, + "[:else,2,12,1,12,5]" => 3 + } + }, + branch_arms: [], + format: :simplecov, + source_path: "a.rb", + language: "ruby" + ) + tuple_results = SlopCop::CoverageData.branch_arm_coverage(tuple_cov, arms) + assert_equal [false, true], tuple_results.map(&:covered) + assert_equal({ 11 => 1 }, SlopCop::CoverageData.dark_branch_misses_by_line(tuple_cov, arms)) + assert SlopCop::CoverageData.branch_kind_compatible?("if", "unless") + refute SlopCop::CoverageData.branch_kind_compatible?("case", "if") + assert SlopCop::CoverageData.span_contains?([1, 1, 5, 1], [2, 1, 3, 1]) + refute SlopCop::CoverageData.span_contains?([1, 1], [2, 1, 3, 1]) + assert_nil SlopCop::CoverageData.coverage_tuple("bad") + + native_arm = SlopCop::CoverageData::NativeBranchArm.new( + branch_id: "b", + arm_id: "native-else", + kind: "if", + member: "else", + decision_span: [10, 1, 12, 3], + arm_span: [12, 1, 12, 5], + hits: 4 + ) + native_cov = SlopCop::CoverageData::FileCoverage.new( + file: File.join(dir, "a.rb"), + lines: [], + branches: {}, + branch_arms: [native_arm], + format: :nil_kill_branch, + source_path: "a.rb", + language: "ruby" + ) + native_results = SlopCop::CoverageData.branch_arm_coverage(native_cov, arms) + assert_equal 1, native_results.size + assert native_results.first.covered + assert_equal :native_branch, native_results.first.source + assert_equal "ruby", SlopCop::CoverageData.arm_language(arms.first) + { + "a.zig" => "zig", + "a.py" => "python", + "a.jsx" => "javascript", + "a.tsx" => "typescript", + "a.go" => "go", + "a.rs" => "rust", + "a.rb" => "ruby", + "README" => "unknown" + }.each do |name, language| + assert_equal language, SlopCop::CoverageData.arm_language(Arm.new(file: name)) + assert_equal language, SlopCop::CoverageData.language_for(name) + end + fallback_id = SlopCop::CoverageData.static_arm_id( + SlopCop::CoverageData::FileCoverage.new(file: File.join(dir, "fallback.py"), lines: [], branches: {}, branch_arms: [], source_path: "", language: ""), + Arm.new(file: File.join(dir, "fallback.py"), kind: "if", member: "then", decision_span: [1, 1, 1, 2], span: [1, 1, 1, 2]) + ) + assert_includes fallback_id, "python" + assert_equal "1:2:3", SlopCop::CoverageData.span_key([1, "2", 3]) + end + end + + def test_coverage_data_falls_back_to_boobytrap_subprocess_output + Dir.mktmpdir do |dir| + raw = File.join(dir, "raw.resultset.json") + File.write(raw, JSON.dump("RSpec" => { "coverage" => {} })) + status = Struct.new(:ok) { def success? = ok } + + Open3.stub :capture3, ["", "bad input", status.new(false)] do + dataset = SlopCop::CoverageData.load_uncached(raw, root: dir) + assert dataset.empty? + end + + Open3.stub :capture3, ["not-json", "", status.new(true)] do + dataset = SlopCop::CoverageData.load_uncached(raw, root: dir) + assert dataset.empty? + end + + file = File.join(dir, "a.rb") + File.write(file, "def call\nend\n") + output = JSON.dump( + "files" => { + file => { + "lines" => [1], + "branches" => {}, + "branch_arms" => [], + "source_path" => "a.rb", + "language" => "ruby", + "format" => "simplecov" + } + } + ) + Open3.stub :capture3, [output, "", status.new(true)] do + dataset = SlopCop::CoverageData.load_uncached(raw, root: dir) + refute dataset.empty? + assert_equal 1, dataset[file].line_hits(1) + end + end + end + + def test_coverage_data_builds_branch_catalog_with_stubbed_syntax + Dir.mktmpdir do |dir| + file = File.join(dir, "src/app.rb") + FileUtils.mkdir_p(File.dirname(file)) + File.write(file, "if ok\n 1\nend\n") + arm = Arm.new( + file: file, + kind: "if", + member: "then", + decision_line: 1, + decision_span: [1, 1, 3, 3], + line: 2, + span: [2, 1, 2, 3] + ) + doc = Decomplex::Syntax::Document.new([arm], "ruby") + SlopCop::CoverageData.stub :load_decomplex_syntax, true do + Decomplex::Syntax.stub :parse, doc do + catalog = SlopCop::CoverageData.branch_catalog(["src/app.rb", "missing.rb"], root: dir) + assert_equal "nil-kill.branch-catalog", catalog.fetch("format") + assert_equal 1, catalog.fetch("files").size + entry = catalog.fetch("files").first + assert_equal "src/app.rb", entry.fetch("path") + assert_equal "ruby", entry.fetch("language") + assert_equal "if", entry.fetch("arms").first.fetch("kind") + end + end + SlopCop::CoverageData.stub :load_decomplex_syntax, false do + assert_empty SlopCop::CoverageData.branch_catalog(["src/app.rb"], root: dir).fetch("files") + end + end + end + + def test_decomplex_risk_reads_fact_files_and_normalizes_sources + Dir.mktmpdir do |dir| + file = File.join(dir, "src/a.rb") + FileUtils.mkdir_p(File.dirname(file)) + File.write(file, "def call\nend\n") + facts = File.join(dir, "facts.json") + File.write(facts, JSON.dump( + "detectors" => { + "z_detector" => { "nested" => [{ "sites" => ["bad", "#{file}:call:3"] }] }, + "a_detector" => [{ "sites" => ["#{file}:call:3"] }], + "state_branch_density" => [ + { + "file" => file, + "method" => "call", + "score" => 2.5, + "decisions" => 3, + "at" => "#{file}:call:3", + "state_refs" => ["@x"], + "predicate" => "@x" + } + ] + } + )) + old = ENV["DECOMPLEX_FACTS_FILE"] + ENV["DECOMPLEX_FACTS_FILE"] = facts + scores = SlopCop::DecomplexRisk.score([file], root: dir) + score = scores.fetch(["src/a.rb", "call"]) + assert_equal 2, score.score + assert_equal %w[z_detector a_detector].sort, score.detectors.sort + density = SlopCop::DecomplexRisk.state_branch_density([file], root: dir) + assert_equal "src/a.rb", density.first.fetch(:file) + assert_equal 3, density.first.fetch(:decisions) + assert_equal({}, SlopCop::DecomplexRisk.score([], root: dir)) + assert_equal([], SlopCop::DecomplexRisk.state_branch_density([], root: dir)) + assert SlopCop::DecomplexRisk.supported_exts.include?(".rb") + SlopCop::DecomplexRisk.stub :load_decomplex_source_filter, false do + assert SlopCop::DecomplexRisk.source_file?(file, root: dir) + refute SlopCop::DecomplexRisk.source_file?(File.join(dir, "README"), root: dir) + refute SlopCop::DecomplexRisk.excluded_path?(file, root: dir, exclude: ["src"]) + end + assert_equal "missing.rb", SlopCop::DecomplexRisk.relpath(File.join(dir, "missing.rb"), dir) + ensure + ENV["DECOMPLEX_FACTS_FILE"] = old + end + end + + def test_decomplex_risk_uses_external_binary_and_handles_failures + Dir.mktmpdir do |dir| + file = File.join(dir, "src/a.rb") + FileUtils.mkdir_p(File.dirname(file)) + File.write(file, "def call\nend\n") + bin = File.join(dir, "fake-decomplex") + File.write(bin, <<~SH) + #!/bin/sh + out="" + prev="" + for arg in "$@"; do + if [ "$prev" = "--output" ]; then out="$arg"; fi + prev="$arg" + done + cat > "$out" <<'JSON' + {"detectors":{"unused_return":[{"sites":["#{file}:call:2"]}],"state_branch_density":[{"file":"#{file}","method":"call","score":1.5,"decisions":2,"state_refs":["@x"],"predicate":"@x"}]}} + JSON + SH + FileUtils.chmod("+x", bin) + mod = SlopCop::DecomplexRisk + old = mod.const_get(:DECOMPLEX_RUST_BINARY) + mod.send(:remove_const, :DECOMPLEX_RUST_BINARY) + mod.const_set(:DECOMPLEX_RUST_BINARY, bin) + scores = mod.score([file], root: dir) + assert_equal 1, scores.fetch(["src/a.rb", "call"]).score + density = mod.state_branch_density([file], root: dir) + assert_equal 1.5, density.first.fetch(:score) + + failing = File.join(dir, "failing-decomplex") + File.write(failing, "#!/bin/sh\nexit 7\n") + FileUtils.chmod("+x", failing) + mod.send(:remove_const, :DECOMPLEX_RUST_BINARY) + mod.const_set(:DECOMPLEX_RUST_BINARY, failing) + assert_equal({}, mod.score([file], root: dir)) + assert_equal([], mod.state_branch_density([file], root: dir)) + ensure + if defined?(mod) && defined?(old) + mod.send(:remove_const, :DECOMPLEX_RUST_BINARY) + mod.const_set(:DECOMPLEX_RUST_BINARY, old) + end + end + end + + def test_decomplex_syntax_parse_and_batch_parse_with_fake_fact_mine + Dir.mktmpdir do |dir| + ruby_file = File.join(dir, "a.rb") + py_file = File.join(dir, "b.py") + File.write(ruby_file, "if ok\n 1\nend\n") + File.write(py_file, "if ok:\n pass\n") + bin = File.join(dir, "fact-mine") + File.write(bin, <<~SH) + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + cat < [ + { "file" => "src/a.rb", "method" => "Owner#call", "kill_rate" => "95%", "gate_status" => "required" }, + { "file" => "src/a.rb", "method" => "Owner#fallback*", "kill_rate" => 0.5, "gate_status" => "advisory" }, + { "file" => "src/b.rb", "method" => "Shared.run", "kill_rate" => 75, "gate_status" => "required" } + ] + } + path = File.join(dir, "mutation.json") + File.write(path, JSON.dump(data)) + index = SlopCop::MutationFacts.load(path, root: dir) + assert index.active? + assert_equal "mutation.json", index.label + strong = index.lookup("./src/a.rb", "Owner#call") + assert strong.strong? + assert_equal "95.0% killed / required", strong.summary + assert_equal strong, index.lookup("src/a.rb", "call") + assert index.lookup("src/a.rb", "anything").weak? + assert_equal "missing", index.status_for("missing.rb", "none").gate_status + + inactive = SlopCop::MutationFacts.empty + assert_nil inactive.status_for("missing.rb", "none") + assert SlopCop::MutationFacts.load(File.join(dir, "missing.json"), root: dir).empty? + bad = File.join(dir, "bad.json") + File.write(bad, "{bad") + assert SlopCop::MutationFacts.load(bad, root: dir).empty? + + from_data = SlopCop::MutationFacts.load_from_data( + { "active" => true, "subjects" => [{ "file" => "x.rb", "method" => "Solo.work", "kill_rate" => 75, "gate_status" => "required" }] }, + label: "inline" + ) + assert_equal "x.rb", from_data.lookup("other.rb", "work").file + assert SlopCop::MutationFacts.load_from_data(nil, label: "none").empty? + + assert_nil SlopCop::MutationFacts.parse_kill_rate(nil) + assert_nil SlopCop::MutationFacts.parse_kill_rate("bad") + assert_equal 42.0, SlopCop::MutationFacts.parse_kill_rate(0.42) + assert_equal "", SlopCop::MutationFacts.normalize_file("", root: dir) + assert_equal "src/a.rb", SlopCop::MutationFacts.normalize_file(File.join(dir, "src/a.rb"), root: dir) + assert_equal "path/file.rb", SlopCop::MutationFacts.clean_file(".\\path\\file.rb") + assert_equal ["A::B.run", "run", "B.run"], SlopCop::MutationFacts.method_aliases("A::B.run") + + moderate = SlopCop::MutationFacts::Fact.new(file: "x", method: "m", kill_rate: 75, gate_status: "required") + weak = SlopCop::MutationFacts::Fact.new(file: "x", method: "m", kill_rate: nil, gate_status: "missing") + assert_equal 1.0, SlopCop::MutationFacts.risk_multiplier(strong, active: false, complexity: 9, history: 1, coverage_gap: 1) + assert_equal 0.9, SlopCop::MutationFacts.risk_multiplier(strong, active: true, complexity: 9, history: 1, coverage_gap: 0) + assert_equal 1.1, SlopCop::MutationFacts.risk_multiplier(moderate, active: true, complexity: 1, history: 0, coverage_gap: 0) + assert_equal 1.9, SlopCop::MutationFacts.risk_multiplier(weak, active: true, complexity: 9, history: 1, coverage_gap: 1) + assert_equal 1.7, SlopCop::MutationFacts.risk_multiplier(weak, active: true, complexity: 9, history: 1, coverage_gap: 0) + assert_equal 1.45, SlopCop::MutationFacts.risk_multiplier(weak, active: true, complexity: 9, history: 0, coverage_gap: 0) + assert_equal "hardened veteran", SlopCop::MutationFacts.profile(strong, active: true, complexity: 9, history: 0, coverage_gap: 0) + assert_equal "partial verification", SlopCop::MutationFacts.profile(moderate, active: true, complexity: 1, history: 0, coverage_gap: 0) + assert_equal "lurking disaster", SlopCop::MutationFacts.profile(weak, active: true, complexity: 9, history: 1, coverage_gap: 1) + assert_nil SlopCop::MutationFacts.profile(weak, active: false, complexity: 9, history: 1, coverage_gap: 1) + end + end +end diff --git a/tools/run_ruby_gem_coverage.rb b/tools/run_ruby_gem_coverage.rb index d8f30eb1a..5538bfdc5 100644 --- a/tools/run_ruby_gem_coverage.rb +++ b/tools/run_ruby_gem_coverage.rb @@ -2,10 +2,26 @@ # frozen_string_literal: true require "fileutils" +require "json" require "rbconfig" root = File.expand_path("..", __dir__) Dir.chdir(root) +FileUtils.rm_rf(File.join(root, "coverage", "ruby-gems")) +FileUtils.rm_rf(File.join(root, "coverage", "ruby-gems-subprocess")) + +def merge_nil_kill_subprocess_coverage(root) + child_resultset = File.join(root, "coverage", "ruby-gems-subprocess", ".resultset.json") + return unless File.file?(child_resultset) + + parent_resultset = File.join(root, "coverage", "ruby-gems", ".resultset.json") + parent = File.file?(parent_resultset) ? JSON.parse(File.read(parent_resultset)) : {} + child = JSON.parse(File.read(child_resultset)) + FileUtils.mkdir_p(File.dirname(parent_resultset)) + File.write(parent_resultset, JSON.pretty_generate(parent.merge(child))) +rescue JSON::ParserError + warn "failed to merge nil-kill subprocess coverage" +end require "simplecov" begin @@ -46,6 +62,8 @@ # because this harness owns the all-gems SimpleCov session. ENV.delete("NIL_KILL_COVERAGE") ENV["NIL_KILL_TMP_DIR"] ||= File.join(root, "tmp", "ruby-gem-coverage", Process.pid.to_s) +ENV["NIL_KILL_SUBPROCESS_COVERAGE"] = "1" +ENV["NIL_KILL_SUBPROCESS_COVERAGE_DIR"] = File.join(root, "coverage", "ruby-gems-subprocess") require "rspec/core" @@ -79,4 +97,8 @@ exit external_status if external_status != 0 && $!.nil? end +at_exit do + merge_nil_kill_subprocess_coverage(root) +end + simplecov_minitest_files.each { |file| require File.expand_path(file, root) } From 9cc7b9311e88921e735ce370a350313dcda7c7a7 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Wed, 1 Jul 2026 08:44:21 +0000 Subject: [PATCH 3/3] ci: build helper binaries for FactMine coverage --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b17797a86..c194dace8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -355,9 +355,11 @@ jobs: mkdir -p tmp/fact-mine-rust-coverage cargo llvm-cov clean --manifest-path gems/fact-mine/Cargo.toml cargo llvm-cov --no-report --manifest-path gems/fact-mine/Cargo.toml --all-features - cargo build --manifest-path gems/decomplex/Cargo.toml + cargo build --release --manifest-path gems/nil-kill/Cargo.toml + cargo build --release --manifest-path gems/decomplex/Cargo.toml FACT_MINE_RUST_BINARY=${{ github.workspace }}/gems/fact-mine/target/llvm-cov-target/debug/fact-mine-rust \ - DECOMPLEX_RUST_BINARY=${{ github.workspace }}/gems/decomplex/target/debug/decomplex-rust \ + NIL_KILL_INFER_RUST_BINARY=${{ github.workspace }}/gems/nil-kill/target/release/nil-kill-infer-rust \ + DECOMPLEX_RUST_BINARY=${{ github.workspace }}/gems/decomplex/target/release/decomplex-rust \ LLVM_PROFILE_FILE='${{ github.workspace }}/gems/fact-mine/target/llvm-cov-target/fact-mine-%p-%32m.profraw' \ bundle exec ruby tools/run_ruby_gem_coverage.rb cargo llvm-cov report \